import { CLOTH_COTTON } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/cloth_dynamics_library.js";
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { createScene } from "./scene.js";
const scene = await createScene({ distance: 6.6, yaw: .15 });
scene.decor(make_box_geometry(3.6, .08, .12), "#495c73", 0, 3, 0);
for (const x of [-1.7, 1.7]) {
scene.decor(make_box_geometry(.08, 3, .08), "#495c73", x, 1.45, 0);
}
const carriage = scene.decor(make_box_geometry(.7, .16, .22), "#e9b68d", 0, 2.85, 0);
const dynamics = CLOTH_COTTON.clone();
Object.assign(dynamics, { slack: 1, damping: .12, substeps: 2, iterations: 8 });
const ribbon = await scene.ribbon(0, 2.8, 0, "#91a5ff", dynamics);
let phase = 0;
const moving = document.getElementById("motion");
scene.onStep(dt => {
if (!moving.checked) return;
phase += dt * 1.4;
const x = .95 * Math.sin(phase);
scene.move(carriage, x, 2.85, 0);
scene.move(ribbon, x, 2.8, 0);
});
// Zero inertia follows the anchor's frame; one feels its full acceleration.
const inertia = document.getElementById("inertia");
inertia.addEventListener("input", () => {
dynamics.inertia = Number(inertia.value);
document.getElementById("amount").value = dynamics.inertia.toFixed(1);
});
document.getElementById("status").hidden = true;
document.querySelector("fieldset").disabled = false;
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 { TransformAttachmentSystem } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { load_model_scene_bundle } from "@woosh/meep-engine/src/engine/asset/load_model_scene_bundle.js";
import { ArrayBufferLoader } from "@woosh/meep-engine/src/engine/asset/loaders/ArrayBufferLoader.js";
import { GLTFSceneBundleAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFSceneBundleAssetLoader.js";
import { MeshSystem } from "@woosh/meep-engine/src/engine/graphics3/MeshSystem.js";
import { MeshSkinSystem } from "@woosh/meep-engine/src/engine/graphics3/MeshSkinSystem.js";
import { MeshSkin } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/MeshSkin.js";
import { SGMesh } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { SHADED_GEOMETRY_EVENT_CHANGE } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/SHADED_GEOMETRY_EVENT_CHANGE.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 { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { ShadeDrawSide } from "@woosh/meep-engine/src/shade/renderer/material/ShadeDrawSide.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";
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 { ClothPin } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothPin.js";
import { ClothSystem } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothSystem.js";
import { ClothColliderSystem } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothColliderSystem.js";
import { cloth_proxy_from_joints } from "@woosh/meep-engine/src/engine/physics/cloth/build/cloth_proxy_from_joints.js";
export async function createScene({ distance = 6.5, yaw = .25 } = {}) {
let meshes, tick = () => {}, playing = true;
// Animate anchors and colliders before the cloth's fixed step.
class ExampleClothSystem extends ClothSystem {
fixedUpdate(dt) {
if (!playing) return;
tick(dt);
super.fixedUpdate(dt);
}
}
const cloth = new ExampleClothSystem();
const engine = await EngineHarness.bootstrap({
configuration(config, engine) {
const scene = EngineHarness.shadeScene(engine);
const loader = new GLTFSceneBundleAssetLoader();
config.addLoader(GameAssetType.ArrayBuffer, new ArrayBufferLoader());
config.addLoader(GameAssetType.ModelGLTF, loader);
config.addLoader(GameAssetType.ModelGLTF_JSON, loader);
meshes = new MeshSystem(engine.graphics, scene, url => load_model_scene_bundle(engine.assetManager, url));
config.addSystem(meshes);
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
config.addSystem(new MeshSkinSystem(engine.graphics, scene));
config.addSystem(new ParentEntitySystem());
config.addSystem(new TransformAttachmentSystem());
config.addSystem(new ClothColliderSystem());
config.addSystem(cloth);
},
});
await EngineHarness.buildBasics({
engine, enableTerrain: false, enableWater: false, enableLights: true,
enableShadows: true, shadowmapResolution: 2048,
focus: new Vector3(0, 1.5, 0), distance, pitch: .18, 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 / 120;
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
const ecd = engine.entityManager.dataset;
ecd.registerComponentType(ClothRig);
ecd.traverseEntities([Light], light => {
if (light.type.getValue() === LightType.DIRECTION) light.intensity.set(6);
});
function material(hex) {
const m = new StandardShadeMaterial();
m.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hex)));
m.roughness_factor = .9;
m.draw_side = ShadeDrawSide.Double;
return m;
}
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), material(color))).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);
}
async function ribbon(x, y, z, color, dynamics) {
const model = new SGMesh();
model.url = new URL("../assets/ribbon.glb", import.meta.url).href;
const transform = new Transform64();
transform.setTranslation(x, y, z);
const entity = new Entity().add(transform).add(model).build(ecd);
const instance = await new Promise((resolve, reject) => {
const deadline = performance.now() + 30000;
function check() {
const loaded = meshes.instance_of(entity);
if (loaded) resolve(loaded);
else if (performance.now() > deadline) reject(new Error("Ribbon failed to load"));
else requestAnimationFrame(check);
}
check();
});
for (const mesh of instance.mesh_entities) {
ecd.getComponent(mesh, ShadedGeometry).material = material(color);
ecd.sendEvent(mesh, SHADED_GEOMETRY_EVENT_CHANGE);
}
const names = instance.prefab.name.filter(name => /^Link_\d+$/.test(name));
const { proxy, groups, error } = cloth_proxy_from_joints(instance.prefab, instance.prefab.skins[0], names);
if (!proxy) throw new Error(error);
// The rig names the mesh whose MeshSkin the proxy indexes; the build says which bones hold it.
const skinned = instance.mesh_entities.find(mesh => ecd.getComponent(mesh, MeshSkin) !== undefined);
const component = Cloth.from(dynamics);
const garment = new Entity().add(new Transform64()).add(ClothRig.from(proxy, skinned)).add(component).build(ecd);
const joints = ecd.getComponent(skinned, MeshSkin).joints;
for (const { joint, particles } of groups) {
ecd.addComponentToEntity(joints[joint], ClothPin.from({ cloth: garment, particles }));
}
return { entity, transform, garment, component };
}
decor(make_box_geometry(12, .15, 10), "#182332", 0, -.1, 0);
document.getElementById("pause").addEventListener("click", event => {
playing = !playing;
event.target.textContent = playing ? "Pause" : "Resume";
event.target.setAttribute("aria-pressed", String(!playing));
});
return { engine, ecd, cloth, decor, move, ribbon, onStep: callback => { tick = callback; } };
}
<!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>Moving anchor · 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); }
h1 { font-size: 16px; margin: 0 0 6px; }
p { color: #a8b6cd; line-height: 1.5; margin: 6px 0 12px; }
fieldset { border: 0; padding: 0; margin: 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; }
#status { font-size: 12px; margin-bottom: 0; }
@media (max-width: 650px) { .panel { left: 10px; bottom: 10px; width: 250px; padding: 12px; } .hint { display: none; } }
</style>
</head>
<body>
<section class="panel" aria-label="Moving anchor controls">
<h1>Moving anchor</h1>
<p>The rail moves; the fabric trails and catches up.</p>
<fieldset disabled>
<label class="toggle"><input id="motion" type="checkbox" checked>Move rail</label><label for="inertia">Inertia <output id="amount">1.0</output></label><input id="inertia" type="range" min="0" max="1" step="0.1" value="1">
<button id="pause" type="button" aria-pressed="false">Pause</button>
</fieldset>
<p id="status" role="status">Loading…</p>
</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-anchor",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A ribbon trails a moving rail, with adjustable anchor inertia.",
"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"
}
}
# Moving anchor
A ribbon trails a moving rail, with adjustable anchor inertia.
Run `npm ci` and `npm run dev`, then open `/demo.html`. Build with `npm run build`.
The included ribbon is a small, original glTF skin with 24 simulated joints. `src/main.js` configures the cloth; `src/scene.js` handles the scene and model loading.
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-anchor");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/cloth-anchor"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});