{"version":1,"duration":93.86666870117188,"stride":11,"shots":[{"start":0,"end":8.066666603088379,"offset":0,"count":485},{"start":8.066666603088379,"end":10.066666603088379,"offset":485,"count":121},{"start":10.066666603088379,"end":13.633333206176758,"offset":606,"count":215},{"start":13.633333206176758,"end":15.433333396911621,"offset":821,"count":109},{"start":15.433333396911621,"end":17.16666603088379,"offset":930,"count":105},{"start":17.16666603088379,"end":19.83333396911621,"offset":1035,"count":161},{"start":19.83333396911621,"end":22.766666412353516,"offset":1196,"count":177},{"start":22.766666412353516,"end":27.766666412353516,"offset":1373,"count":301},{"start":27.766666412353516,"end":28.566667556762695,"offset":1674,"count":49},{"start":28.566667556762695,"end":29.633333206176758,"offset":1723,"count":65},{"start":29.633333206176758,"end":31.399999618530273,"offset":1788,"count":107},{"start":31.399999618530273,"end":32.766666412353516,"offset":1895,"count":83},{"start":32.766666412353516,"end":34.96666717529297,"offset":1978,"count":133},{"start":34.96666717529297,"end":36.66666793823242,"offset":2111,"count":103},{"start":36.66666793823242,"end":38.36666488647461,"offset":2214,"count":103},{"start":38.36666488647461,"end":41.16666793823242,"offset":2317,"count":169},{"start":41.16666793823242,"end":45.16666793823242,"offset":2486,"count":241},{"start":45.16666793823242,"end":48.56666564941406,"offset":2727,"count":205},{"start":48.56666564941406,"end":52.29999923706055,"offset":2932,"count":225},{"start":52.29999923706055,"end":57.16666793823242,"offset":3157,"count":293},{"start":57.16666793823242,"end":61.63333511352539,"offset":3450,"count":269},{"start":61.63333511352539,"end":65.16666412353516,"offset":3719,"count":213},{"start":65.16666412353516,"end":68.73332977294922,"offset":3932,"count":215},{"start":68.73332977294922,"end":71.4000015258789,"offset":4147,"count":161},{"start":71.4000015258789,"end":73.63333129882812,"offset":4308,"count":135},{"start":73.63333129882812,"end":74.9000015258789,"offset":4443,"count":77},{"start":74.9000015258789,"end":78.53333282470703,"offset":4520,"count":219},{"start":78.53333282470703,"end":84.63333129882812,"offset":4739,"count":367},{"start":84.63333129882812,"end":93.86666870117188,"offset":5106,"count":555},{"start":93.86666870117188,"end":93.86666870117188,"offset":5661,"count":1}]}
{
"clip": "WAVEFILE",
"duration": 93.63333129882812,
"audioDelay": 5.333333333333333,
"character": "Hatsune Miku",
"humanoid": {
"head": "頭",
"chest": "上半身",
"hips": "下半身"
},
"legs": [
{
"chain": [
"左足",
"左ひざ",
"左足首"
],
"target": "Ankle goal 0",
"toe": {
"effector": "左つま先",
"target": "左つま先IK"
},
"guide": "Knee guide 0",
"thighFrame": "Thigh frame 0",
"authoredTarget": "左足IK"
},
{
"chain": [
"右足",
"右ひざ",
"右足首"
],
"target": "Ankle goal 1",
"toe": {
"effector": "右つま先",
"target": "右つま先IK"
},
"guide": "Knee guide 1",
"thighFrame": "Thigh frame 1",
"authoredTarget": "右足IK"
}
],
"secondary": [
"ネクタイ1",
"ネクタイ2",
"ネクタイ3",
"左髪1",
"左髪2",
"左髪3",
"左髪4",
"左髪5",
"左髪6",
"左スカート前",
"左スカート後",
"右髪1",
"右髪2",
"右髪3",
"右髪4",
"右髪5",
"右髪6",
"右スカート前",
"右スカート後",
"前髪1",
"前髪2",
"前髪3",
"ネクタイ4",
"左髪7",
"右髪7",
"左スカート前先",
"左スカート後先",
"右スカート前先",
"右スカート後先",
"前髪1先",
"前髪2先",
"前髪3先",
"左髪1.midpoint",
"左髪2.midpoint",
"左髪3.midpoint",
"左髪4.midpoint",
"左髪5.midpoint",
"左髪6.midpoint",
"右髪1.midpoint",
"右髪2.midpoint",
"右髪3.midpoint",
"右髪4.midpoint",
"右髪5.midpoint",
"右髪6.midpoint",
"Front flap R.root",
"Front flap R.middle",
"Front flap R.hem",
"Front flap L.root",
"Front flap L.middle",
"Front flap L.hem",
"Front flap R.end",
"Front flap L.end"
],
"clothAnchors": [
"左髪1",
"右髪1",
"ネクタイ1",
"左スカート前",
"右スカート前",
"左スカート後",
"右スカート後"
],
"colliders": [
{
"name": "頭",
"bone": "頭",
"shape": 0,
"size": [
0.12800000190734864,
0.00800000011920929,
0.00800000011920929
],
"translation": [
0.00800000011920929,
0.12800000190734864,
-0.01600000023841858
],
"rotation": [
0,
0,
0,
1
]
},
{
"name": "腕右",
"bone": "右腕",
"shape": 2,
"size": [
0.03200000047683716,
0.21600000381469728,
0.00800000011920929
],
"translation": [
-0.08800000190734864,
-0.048000001907348634,
0
],
"rotation": [
0,
0,
-0.47024180524378384,
0.8825376165360133
]
},
{
"name": "腕左",
"bone": "左腕",
"shape": 2,
"size": [
0.03200000047683716,
0.21600000381469728,
0.00800000011920929
],
"translation": [
0.08800000190734864,
-0.048000001907348634,
0
],
"rotation": [
0,
0,
0.4710117916059218,
0.8821269138668084
]
},
{
"name": "右肘",
"bone": "右ひじ",
"shape": 2,
"size": [
0.048000001907348634,
0.18399999618530274,
0.00800000011920929
],
"translation": [
-0.09600000381469727,
-0.07199999809265137,
0.01600000023841858
],
"rotation": [
0,
0,
-0.4305110272893039,
0.9025853175087152
]
},
{
"name": "左肘",
"bone": "左ひじ",
"shape": 2,
"size": [
0.048000001907348634,
0.18399999618530274,
0.00800000011920929
],
"translation": [
0.09600000381469727,
-0.07199999809265137,
0.01600000023841858
],
"rotation": [
0,
0,
0.4305110272893039,
0.9025853175087152
]
},
{
"name": "右もも1",
"bone": "右足",
"shape": 2,
"size": [
0.065,
0.26,
0.00800000011920929
],
"translation": [
0.00800000011920929,
-0.19,
0.014
],
"rotation": [
-0.029664610720971638,
0.0003106583752941206,
0.010467173125728844,
0.9995050538386002
]
},
{
"name": "左もも1",
"bone": "左足",
"shape": 2,
"size": [
0.065,
0.26,
0.08
],
"translation": [
-0.00800000011920929,
-0.19,
0.014
],
"rotation": [
-0.029664610720971638,
-0.0003106583752941206,
-0.010467173125728844,
0.9995050538386002
]
},
{
"name": "右足",
"bone": "右ひざ",
"shape": 2,
"size": [
0.03200000047683716,
0.3840000152587891,
0.08
],
"translation": [
-0.01600000023841858,
-0.19200000762939454,
0
],
"rotation": [
0.03489564861244324,
0.0005177252326264742,
-0.014825714174409877,
0.9992808533479058
]
},
{
"name": "左足",
"bone": "左ひざ",
"shape": 2,
"size": [
0.03200000047683716,
0.3840000152587891,
0.08
],
"translation": [
0.01600000023841858,
-0.19200000762939454,
0
],
"rotation": [
0.03489564861244324,
-0.0005177252326264742,
0.014825714174409877,
0.9992808533479058
]
},
{
"name": "下半身",
"bone": "下半身",
"shape": 2,
"size": [
0.08,
0.048000001907348634,
0.08
],
"translation": [
0,
-0.048000001907348634,
0.00800000011920929
],
"rotation": [
-0.08976345393056118,
0,
0,
0.9959631129406631
]
},
{
"name": "あご",
"bone": "頭",
"shape": 2,
"size": [
0.06400000095367432,
0.01600000023841858,
0.08
],
"translation": [
0,
0.04,
0.048000001907348634
],
"rotation": [
0,
0,
0.7071066702003035,
0.7071068921727742
]
},
{
"name": "首",
"bone": "首",
"shape": 2,
"size": [
0.024000000953674317,
0.048000001907348634,
0.08
],
"translation": [
0,
0,
0
],
"rotation": [
0.09497704749041593,
0,
0,
0.9954794625957902
]
},
{
"name": "Torso",
"bone": "上半身",
"shape": 2,
"size": [
0.085,
0.054,
0
],
"translation": [
0,
0.1585,
-0.0132
],
"rotation": [
0,
0,
0,
1
]
}
],
"garments": [
{
"name": "Twin tails and bangs",
"kind": "hair",
"joints": [
"左髪1",
"左髪2",
"左髪3",
"左髪4",
"左髪5",
"左髪6",
"右髪1",
"右髪2",
"右髪3",
"右髪4",
"右髪5",
"右髪6",
"前髪1",
"前髪2",
"前髪3",
"左髪7",
"右髪7",
"前髪1先",
"前髪2先",
"前髪3先",
"左髪1.midpoint",
"左髪2.midpoint",
"左髪3.midpoint",
"左髪4.midpoint",
"左髪5.midpoint",
"左髪6.midpoint",
"右髪1.midpoint",
"右髪2.midpoint",
"右髪3.midpoint",
"右髪4.midpoint",
"右髪5.midpoint",
"右髪6.midpoint"
]
},
{
"name": "Skirt",
"kind": "garment",
"joints": [
"左スカート前",
"左スカート後",
"右スカート前",
"右スカート後",
"左スカート前先",
"左スカート後先",
"右スカート前先",
"右スカート後先"
]
},
{
"name": "Front skirt flaps",
"kind": "front",
"joints": [
"Front flap R.root",
"Front flap R.middle",
"Front flap R.hem",
"Front flap L.root",
"Front flap L.middle",
"Front flap L.hem",
"Front flap R.end",
"Front flap L.end"
]
},
{
"name": "Necktie",
"kind": "tie",
"joints": [
"ネクタイ1",
"ネクタイ2",
"ネクタイ3",
"ネクタイ4"
]
}
],
"hairResolution": {
"segmentsPerPigtail": 12,
"originalSegmentsPerPigtail": 6,
"addedJoints": 12
},
"skirtBlend": {
"pinHeight": 0.92,
"fullClothHeight": 0.8,
"anchor": "下半身",
"interpolation": "smoothstep"
},
"frontFlapBlend": {
"pinHeight": 1,
"fullClothHeight": 0.86,
"anchor": "下半身",
"interpolation": "smoothstep",
"sharedLayers": true,
"seamBinding": "nearest fabric triangle, exact binding at welded positions"
},
"garmentColliders": [],
"skirtCoverage": {
"binding": "hips and thighs with restrained cloth contribution",
"clothWeight": 0.08,
"hemExtension": 0.035
}
}
// AudioContext.currentTime is ahead of the sound leaving the device. Project
// its output timestamp to this frame so visual cues follow the audible sample.
export function audibleAudioTime(context, nowMs) {
const stamp = context.getOutputTimestamp?.();
if (stamp && stamp.performanceTime > 0 && Number.isFinite(stamp.contextTime)) {
const time = stamp.contextTime + (nowMs - stamp.performanceTime) / 1000;
return Math.max(0, Math.min(context.currentTime, time));
}
const latency = Math.max(0, context.baseLatency || 0) + Math.max(0, context.outputLatency || 0);
return Math.max(0, context.currentTime - latency);
}
import { AnimationCurve } from "@woosh/meep-engine/src/engine/animation/curve/AnimationCurve.js";
import { curve_from_track_data_linear } from "@woosh/meep-engine/src/engine/animation/clip/curve_from_track_data_linear.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// Offline samples of the original MMD camera, in the character's world space.
// Separate shots preserve authored cuts without interpolating across them.
export class CinematicCameraMotion {
constructor(metadata, buffer) {
this.duration = metadata.duration;
const samples = new Float32Array(buffer);
this.shots = metadata.shots.map(shot => {
const values = samples.subarray(shot.offset * metadata.stride, (shot.offset + shot.count) * metadata.stride);
const times = Float32Array.from({ length: shot.count }, (_, i) => values[i * metadata.stride]);
const curves = Array.from({ length: 10 }, (_, i) => {
const curve = new AnimationCurve();
curve_from_track_data_linear(curve, values, times, metadata.stride, i + 1);
return curve;
});
return { start: shot.start, curves };
});
this.values = new Float64Array(10);
this.eye = new Vector3();
this.target = new Vector3();
this.up = new Vector3();
}
sample(time) {
time = Math.max(0, Math.min(time, this.duration));
const shot = this.shots.findLast(shot => shot.start <= time);
for (let i = 0; i < 10; i++) this.values[i] = shot.curves[i].evaluate(time);
return this.values;
}
apply(time, controller, camera) {
const v = this.sample(time);
this.eye.set(v[0], v[1], v[2]);
this.target.set(v[3], v[4], v[5]);
this.up.set(v[6], v[7], v[8]).normalize();
// The native orbit controller retains this pose when cinematic mode
// ends, so the first user drag continues from the current shot.
controller.setFromEyeAndTarget(this.eye, this.target, this.up);
camera.fov.set(v[9]);
}
}
import { Gizmo } from "@woosh/meep-engine/src/engine/graphics/render/gizmo/Gizmo.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_evaluate_world } from "@woosh/meep-engine/src/engine/ecs/transform/t64_evaluate_world.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 { 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 { TransparencyMode } from "@woosh/meep-engine/src/shade/renderer/material/TransparencyMode.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
const world = new Transform64(), point = new Vector3();
const originals = new Map(), ghosts = new Map();
const from = [0, 0, 0], to = [0, 0, 0];
export function setDebugCharacter(ecd, instance, enabled) {
for (const entity of instance.mesh_entities) {
const geometry = ecd.getComponent(entity, ShadedGeometry);
if (!originals.has(entity)) {
originals.set(entity, geometry.material);
const ghost = geometry.material.clone();
ghost.transparency_mode = TransparencyMode.Transparent;
ghost.diffuse_color.setA(.35);
ghost.needsUpdate = true;
ghosts.set(entity, ghost);
}
geometry.material = enabled ? ghosts.get(entity) : originals.get(entity);
ecd.sendEvent(entity, SHADED_GEOMETRY_EVENT_CHANGE);
}
}
function position(ecd, entity) {
t64_evaluate_world(world, ecd, entity, []);
return [world.translation_x, world.translation_y, world.translation_z];
}
function localLine(a, b) {
point.set(...a).applyMatrix4(world);
from[0] = point.x; from[1] = point.y; from[2] = point.z;
point.set(...b).applyMatrix4(world);
to[0] = point.x; to[1] = point.y; to[2] = point.z;
Gizmo.draw_line(from, to);
}
function wireCapsule(radius, height) {
const half = height / 2;
for (let i = 0; i < 20; i++) {
const a = i * Math.PI / 10, b = (i + 1) * Math.PI / 10;
for (const y of [-half, half]) localLine([radius * Math.cos(a), y, radius * Math.sin(a)], [radius * Math.cos(b), y, radius * Math.sin(b)]);
}
for (let plane = 0; plane < 4; plane++) {
const azimuth = plane * Math.PI / 2, x = Math.cos(azimuth), z = Math.sin(azimuth);
localLine([radius * x, -half, radius * z], [radius * x, half, radius * z]);
for (const sign of [-1, 1]) for (let i = 0; i < 8; i++) {
const a = i * Math.PI / 16, b = (i + 1) * Math.PI / 16;
localLine([radius * Math.cos(a) * x, sign * (half + radius * Math.sin(a)), radius * Math.cos(a) * z],
[radius * Math.cos(b) * x, sign * (half + radius * Math.sin(b)), radius * Math.cos(b) * z]);
}
}
}
export function drawClothDebug(ecd, instance, cloths, views) {
const joints = instance.skins[0].joints;
Gizmo.color = [.38, .96, .77, 1];
for (const { proxy } of views.cloth ? cloths : []) {
const points = Array.from(proxy.joint, index => position(ecd, joints[index]));
const anchor = position(ecd, joints[proxy.anchor]);
Gizmo.draw_wire_sphere(anchor, .018);
for (let i = 0; i < points.length; i++) {
Gizmo.draw_line(proxy.joint_parent[i] < 0 ? anchor : points[proxy.joint_parent[i]], points[i]);
Gizmo.draw_solid_sphere(points[i], .009);
}
}
if (!views.colliders && !views.clearance) return;
ecd.traverseEntities([Collider, ClothCollider], (collider, cloth, entity) => {
const clearance = cloth.mask === 1;
if (clearance ? !views.clearance : !views.colliders) return;
t64_evaluate_world(world, ecd, entity, []);
Gizmo.color = clearance ? [.46, .71, 1, .8] : [1, .68, .38, .85];
const shape = collider.shape, padding = cloth.inflation;
if (shape.isSphereShape3D) {
Gizmo.draw_wire_sphere([world.translation_x, world.translation_y, world.translation_z], shape.radius + padding);
} else if (shape.isCapsuleShape3D) {
wireCapsule(shape.radius + padding, shape.height);
} else if (shape.isBoxShape3D) {
const h = shape.half_extents, half = [h.x + padding, h.y + padding, h.z + padding];
const corners = Array.from({ length: 8 }, (_, i) => half.map((x, axis) => i & (1 << axis) ? x : -x));
for (let i = 0; i < 8; i++) for (let axis = 0; axis < 3; axis++) {
if (!(i & (1 << axis))) localLine(corners[i], corners[i | (1 << axis)]);
}
}
});
}
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { System } from "@woosh/meep-engine/src/engine/ecs/System.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 { TransformAttachment } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachment.js";
import { TransformAttachmentSystem } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";
import { TRANSFORM_ATTACHMENT_EVENT_CHANGE } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TRANSFORM_ATTACHMENT_EVENT_CHANGE.js";
import { t64_read_rotation } from "@woosh/meep-engine/src/engine/ecs/transform/t64_read_rotation.js";
import { t64_evaluate_world } from "@woosh/meep-engine/src/engine/ecs/transform/t64_evaluate_world.js";
import { GPUStateAuthorityFlags } from "@woosh/meep-engine/src/engine/ecs/gpu/GPUStateAuthorityFlags.js";
import { gpu_authority_revoke } from "@woosh/meep-engine/src/engine/ecs/gpu/gpu_authority_revoke.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 { SoundAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/SoundAssetLoader.js";
import { GLTFSceneBundleAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFSceneBundleAssetLoader.js";
import { ImageBitmapAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageBitmapAssetLoader.js";
import { MeshSystem } from "@woosh/meep-engine/src/engine/graphics3/MeshSystem.js";
import { DebugDrawSystem } from "@woosh/meep-engine/src/engine/graphics3/DebugDrawSystem.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { SGMesh } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.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 { pose_evaluate_local } from "@woosh/meep-engine/src/shade/renderer/animation/pose/pose_evaluate_local.js";
import { PosePlayback } from "@woosh/meep-engine/src/shade/renderer/animation/pose/PosePlayback.js";
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 { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import Quaternion from "@woosh/meep-engine/src/core/geom/Quaternion.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import { CapsuleShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/CapsuleShape3D.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.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 { cloth_proxy_from_joints } from "@woosh/meep-engine/src/engine/physics/cloth/build/cloth_proxy_from_joints.js";
import { WorkerClothSystem } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/WorkerClothSystem.js";
import { makeClothWorker } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/makeClothWorker.js";
import { ClothColliderSystem } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothColliderSystem.js";
import { ClothCollider } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/ClothCollider.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { CLOTH_HAIR, CLOTH_COTTON } from "@woosh/meep-engine/src/engine/physics/cloth/ecs/cloth_dynamics_library.js";
import { AssetManagerBufferProvider } from "@woosh/meep-engine/src/engine/sound/sopra/asset/AssetManagerBufferProvider.js";
import { SampleAudioClip } from "@woosh/meep-engine/src/engine/sound/sopra/definition/clip/SampleAudioClip.js";
import { EventDescription } from "@woosh/meep-engine/src/engine/sound/sopra/definition/EventDescription.js";
import { DanceTransport } from "./transport.js";
import { drawClothDebug, setDebugCharacter } from "./clothDebug.js";
import { audibleAudioTime } from "./audioClock.js";
import { CinematicCameraMotion } from "./cinematicCamera.js";
import { Camera } from "@woosh/meep-engine/src/engine/graphics/ecs/camera/Camera.js";
import TopDownCameraController from "@woosh/meep-engine/src/engine/graphics/ecs/camera/topdown/TopDownCameraController.js";
const $ = id => document.getElementById(id);
const transport = new DanceTransport();
const options = { physics: true, muted: false, cinematic: true };
let cameraMotion;
const debug = { cloth: false, colliders: false, clearance: false };
const hasDebug = () => debug.cloth || debug.colliders || debug.clearance;
let meshes, dancer = -1, instance, poses = [], playback, music, sopra, phraseDuration, audioDelay;
let musicCycle = -1;
let poseCycle = 0;
const cloths = [];
const HAIR_LAYER = 1;
const GARMENT_LAYER = 2;
const FRONT_LAYER = 4;
const CLOTH_LAYERS = HAIR_LAYER | GARMENT_LAYER | FRONT_LAYER;
// Small hair-only margins; the collider primitives themselves fit the body.
const HAIR_ARM_CLEARANCE = .04;
const HAIR_LEG_CLEARANCE = .08;
const HAIR_FLOOR_CLEARANCE = .16;
let secondaryPoses = [];
let clothSystem;
// Pose evaluation stays on the CPU for this character, using Meep's existing
// evaluator. A GPU clip would own the same joint rows the IK solver needs to
// modify. Explicitly transfer authority rather than letting two writers race.
class DancePoseSystem extends System {
update() { updatePose(); }
}
class DanceWorkerClothSystem extends WorkerClothSystem {
fixedUpdate(dt) {
// The transport freezes both pose and physical state when paused.
if (transport.playing && options.physics) {
updatePose();
super.fixedUpdate(dt);
}
}
}
const engine = await EngineHarness.bootstrap({
configuration(config, engine) {
const scene = EngineHarness.shadeScene(engine);
const loader = new GLTFSceneBundleAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, loader);
config.addLoader(GameAssetType.ModelGLTF_JSON, loader);
config.addLoader(GameAssetType.ArrayBuffer, new ArrayBufferLoader());
config.addLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
config.addLoader(GameAssetType.Sound, new SoundAssetLoader(engine.sound.context));
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 DebugDrawSystem(engine.graphics));
config.addSystem(new ParentEntitySystem());
config.addSystem(new TransformAttachmentSystem());
config.addSystem(new DancePoseSystem());
config.addSystem(new ClothColliderSystem());
clothSystem = new DanceWorkerClothSystem({ worker_factory: makeClothWorker });
config.addSystem(clothSystem);
},
});
await EngineHarness.buildBasics({
engine, enableTerrain: false, enableWater: false, enableLights: true,
enableShadows: true, shadowmapResolution: 2048,
focus: new Vector3(0, .9, 0), distance: 3.7, pitch: .15, yaw: .2,
cameraFarDistance: 100, showFps: false,
});
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
const ecd = engine.entityManager.dataset;
const cameraEntry = ecd.getAnyComponent(Camera);
const cameraController = ecd.getComponent(cameraEntry.entity, TopDownCameraController);
function endCinematicCamera(event) {
if (!engine.graphics.domElement.contains(event.target)) return;
options.cinematic = false;
$("cinematic").checked = false;
}
// dragStart fires before Meep applies the first pan/orbit delta.
engine.devices.pointer.on.dragStart.add((position, event) => endCinematicCamera(event));
engine.devices.pointer.on.wheel.add((delta, position, event) => endCinematicCamera(event));
engine.entityManager.fixedUpdateStepSize = 1 / 120;
ecd.traverseEntities([Light], light => {
if (light.type.getValue() === LightType.DIRECTION) light.intensity.set(6);
});
ecd.registerComponentType(ClothRig);
function material(hex, metallic = 0) {
const m = new StandardShadeMaterial();
m.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hex)));
m.roughness_factor = .7;
m.metallic_factor = metallic;
return m;
}
function decor(geometry, color, x, y, z) {
const t = new Transform64();
t.setTranslation(x, y, z);
return new Entity().add(t).add(ShadedGeometry.from(meshlet_geometry_build_from_geometry(geometry), material(color))).build(ecd);
}
const floor = decor(make_box_geometry(40, .1, 40), "#0b1223", 0, -.35, 0);
const stage = decor(make_cylinder_geometry(1.8, 1.8, .25, 64), "#263752", 0, -.125, 0);
for (const [entity, shape] of [[floor, BoxShape3D.from(20, .05, 20)], [stage, BoxShape3D.from(1.8, .125, 1.8)]]) {
const collider = new Collider();
collider.shape = shape;
ecd.addComponentToEntity(entity, collider);
ecd.addComponentToEntity(entity, ClothCollider.from({ inflation: .002, friction_scale: 0, mask: CLOTH_LAYERS }));
const hairCollider = new Collider();
hairCollider.shape = shape;
new Entity().add(ecd.getComponent(entity, Transform64).clone()).add(hairCollider)
.add(ClothCollider.from({ inflation: HAIR_FLOOR_CLEARANCE, friction_scale: 0, mask: HAIR_LAYER })).build(ecd);
}
decor(make_cylinder_geometry(1.85, 1.85, .035, 64), "#5bddbd", 0, -.24, 0);
for (let i = -3; i <= 3; i++) {
decor(make_box_geometry(.025, 3, .025), i % 2 ? "#7b8bdd" : "#4fc7ba", i * .7, 1.3, 2.6);
}
const model = new SGMesh();
// Resolve relative to the entry document so hashed JS has the same URLs in a build.
model.url = new URL("./assets/miku-wavefile.glb", document.baseURI).href;
const placement = new Transform64();
placement.setRotation(0, 1, 0, 0);
placement.updateMatrix();
dancer = new Entity().add(placement).add(model).build(ecd);
const localRotation = new Quaternion(), parentRotation = new Quaternion();
const scratchWorld = new Transform64();
const referenceRotations = [new Quaternion(), new Quaternion(), new Quaternion()];
let legs = [];
function updatePose() {
if (!instance || !playback) return;
const seconds = transport.seconds(audibleAudioTime(engine.sound.context, performance.now()));
if (options.cinematic && cameraMotion) {
cameraMotion.apply(seconds % phraseDuration, cameraController, cameraEntry.component);
}
const cycle = Math.floor(seconds / phraseDuration);
playback.time = Math.min(seconds % phraseDuration, playback.clip.duration);
if (transport.playing && musicCycle !== cycle) {
music?.stop();
music = sopra.playEvent(musicDescription, { startTime: transport.startedAt + cycle * phraseDuration + audioDelay });
music.setGainDb(options.muted ? -100 : 0);
musicCycle = cycle;
}
for (const p of poses) {
pose_evaluate_local(p.local, p.entity, p.rest, [playback]);
p.local.updateMatrix();
ecd.sendEvent(p.entity, TRANSFORM_ATTACHMENT_EVENT_CHANGE);
}
for (const leg of legs) applyReferenceLeg(leg);
if (cycle !== poseCycle) resetSecondaryPhysics();
poseCycle = cycle;
$("progress").value = (seconds % phraseDuration) / phraseDuration;
}
function applyReferenceLeg(leg) {
// Play the converted MMD limb pose, including its knee bend and twist.
for (let i = 0; i < 3; i++) {
t64_evaluate_world(scratchWorld, ecd, leg.frames[i], []);
t64_read_rotation(referenceRotations[i], scratchWorld);
}
t64_evaluate_world(scratchWorld, ecd, ecd.getComponent(leg.chain[0], TransformAttachment).parent, []);
t64_read_rotation(parentRotation, scratchWorld);
for (let i = 0; i < 3; i++) {
const q = localRotation.copyInverse(i === 0 ? parentRotation : referenceRotations[i - 1]);
q.multiply(referenceRotations[i]);
const local = ecd.getComponent(leg.chain[i], TransformAttachment).transform;
local.setRotation(q.x, q.y, q.z, q.w);
local.updateMatrix();
ecd.sendEvent(leg.chain[i], TRANSFORM_ATTACHMENT_EVENT_CHANGE);
}
}
function attachCloth(proxy, dynamics, garments, layer) {
const component = Cloth.from(dynamics, { layer });
const entity = new Entity().add(new Transform64()).add(ClothRig.from(proxy)).add(component).build(ecd);
garments.set(entity, instance);
cloths.push({ entity, component, proxy });
}
function setupRig(rig) {
playback = new PosePlayback();
playback.clip = instance.clips.find(c => c.name === rig.clip);
if (!playback.clip) throw new Error("Converted dance clip is missing");
playback.weight = 1;
playback.time = 0;
const targets = new Set(playback.clip.channels.map(c => c.target));
legs = rig.legs.map(leg => ({
chain: leg.chain.map(name => instance.entity_of(name)),
frames: [leg.thighFrame, leg.guide, leg.target].map(name => instance.entity_of(name)),
}));
for (const leg of legs) {
if ([...leg.chain, ...leg.frames].includes(-1)) throw new Error("Converted limb rig is incomplete");
for (const entity of leg.chain) targets.add(entity);
}
poses = [...targets].map(entity => {
gpu_authority_revoke(ecd, entity, GPUStateAuthorityFlags.TransformAttachment);
const local = ecd.getComponent(entity, TransformAttachment).transform;
return { entity, local, rest: local.clone() };
});
secondaryPoses = rig.secondary.map(name => instance.entity_of(name)).filter(entity => entity !== -1).map(entity => {
const local = ecd.getComponent(entity, TransformAttachment).transform;
return { entity, local, rest: local.clone() };
});
// ClothWorld accepts a model-instance provider. Each garment has its own
// component entity and anchor, but drives joints of the same drawn instance.
const garments = new Map();
clothSystem.world.models = { instance_of: entity => garments.get(entity) ?? meshes.instance_of(entity) };
for (const garment of rig.garments) {
const { proxy, error } = cloth_proxy_from_joints(instance.prefab, instance.prefab.skins[0], garment.joints, { name: garment.name });
if (!proxy) throw new Error(error);
const dynamics = (garment.kind === "hair" ? CLOTH_HAIR : CLOTH_COTTON).clone();
const profile = {
hair: { bend: .25, slack: .25, damping: .15, thickness: .006 },
garment: { bend: .4, slack: .06, damping: .15, thickness: .004 },
front: { bend: .3, slack: .2, damping: .2, thickness: .004 },
tie: { bend: .08, slack: .4, damping: .12, thickness: .004 },
}[garment.kind];
Object.assign(dynamics, profile, { stretch: 1, substeps: 2, iterations: 8 });
const layer = garment.kind === "hair" ? HAIR_LAYER : garment.kind === "front" ? FRONT_LAYER : GARMENT_LAYER;
attachCloth(proxy, dynamics, garments, layer);
}
for (const spec of rig.colliders) {
const thigh = spec.shape === 2 && /^(左|右)足$/.test(spec.bone);
addBodyClothCollider(spec, thigh ? .006 : .002, CLOTH_LAYERS);
if (spec.shape === 2 && /^(左|右)(腕|ひじ|足|ひざ)$/.test(spec.bone)) {
const clearance = /腕|ひじ/.test(spec.bone) ? HAIR_ARM_CLEARANCE : HAIR_LEG_CLEARANCE;
addBodyClothCollider(spec, clearance, HAIR_LAYER);
}
}
}
function addBodyClothCollider(spec, inflation, mask) {
const offset = new TransformAttachment();
offset.parent = instance.entity_of(spec.bone);
offset.transform.setTranslation(...spec.translation);
offset.transform.setRotation(...spec.rotation);
offset.transform.updateMatrix();
const collider = new Collider();
collider.shape = spec.shape === 0 ? SphereShape3D.from(spec.size[0])
: spec.shape === 1 ? BoxShape3D.from(...spec.size) : CapsuleShape3D.from(spec.size[0], spec.size[1]);
new Entity().add(new Transform64()).add(offset).add(collider)
.add(ClothCollider.from({ inflation, friction_scale: 0, mask })).build(ecd);
}
function startMusic() {
music?.stop();
transport.play(engine.sound.context.currentTime);
musicCycle = -1;
updatePose();
$("play").textContent = "Pause";
$("play").classList.remove("awaiting-play");
}
let musicDescription;
try {
const rigRequest = fetch(new URL("./assets/rig.json", document.baseURI)).then(r => {
if (!r.ok) throw new Error(`Rig metadata: HTTP ${r.status}`);
return r.json();
});
const cameraRequest = Promise.all(["camera-motion.json", "camera-motion.bin"].map(async name => {
const response = await fetch(new URL(`./assets/${name}`, document.baseURI));
if (!response.ok) throw new Error(`Camera motion: HTTP ${response.status}`);
return name.endsWith(".json") ? response.json() : response.arrayBuffer();
})).then(([metadata, buffer]) => new CinematicCameraMotion(metadata, buffer));
const provider = new AssetManagerBufferProvider(engine.assetManager);
sopra = engine.sound.obtainSopra(provider);
// Match three.js's dry output for the already-mastered soundtrack. Keep
// Sopra scheduling and controls, without the game mixer's compression.
const musicOutput = sopra.busGraph.getOutput("music");
sopra.busGraph.setVolume("music", 1);
musicOutput.disconnect();
musicOutput.connect(engine.sound.context.destination);
const audioUrl = new URL("./assets/wavefile_short.mp3", document.baseURI).href;
const audioRequest = provider.get(audioUrl, false);
// Wait for the engine's existing async model loader; do not parse at runtime.
const modelRequest = new Promise((resolve, reject) => {
const deadline = performance.now() + 45000;
const check = () => {
instance = meshes.instance_of(dancer);
if (instance) resolve(instance);
else if (performance.now() > deadline) reject(new Error("Character failed to load"));
else requestAnimationFrame(check);
};
check();
});
const [rig, buffer, convertedCamera] = await Promise.all([rigRequest, audioRequest, cameraRequest, modelRequest]);
cameraMotion = convertedCamera;
audioDelay = rig.audioDelay;
// Preserve three.js MMDAnimationHelper's timing and two-second afterglow.
phraseDuration = Math.max(rig.duration, audioDelay + buffer.duration) + 2;
musicDescription = EventDescription.from("dance-music", SampleAudioClip.from(audioUrl, {
loop: false,
}), { busId: "music" });
setupRig(rig);
updatePose();
$("play").disabled = false;
$("restart").disabled = false;
$("cinematic").disabled = false;
$("status").textContent = "";
$("status").hidden = true;
if (hasDebug()) setDebugCharacter(ecd, instance, true);
} catch (error) {
playback = null;
poses = [];
$("status").textContent = `Unable to load: ${error.message}`;
console.error(error);
}
$("play").addEventListener("click", async () => {
if (transport.playing) {
transport.pause(audibleAudioTime(engine.sound.context, performance.now()));
music?.stop();
updatePose();
$("play").textContent = "Play";
$("play").classList.add("awaiting-play");
} else {
await engine.sound.resumeContext();
startMusic();
}
});
$("restart").addEventListener("click", () => {
transport.restart(engine.sound.context.currentTime);
poseCycle = 0;
resetSecondaryPhysics();
if (transport.playing) startMusic();
updatePose();
});
$("mute").addEventListener("click", () => {
options.muted = !options.muted;
$("mute").setAttribute("aria-pressed", String(options.muted));
$("mute").setAttribute("aria-label", options.muted ? "Unmute" : "Mute");
$("mute").title = options.muted ? "Unmute" : "Mute";
music?.setGainDb(options.muted ? -100 : 0);
});
$("physics").addEventListener("change", () => {
options.physics = $("physics").checked;
for (const { component } of cloths) component.blend = options.physics ? 1 : 0;
if (!options.physics) restoreSecondaryPose();
else resetSecondaryPhysics();
});
$("cinematic").addEventListener("change", () => {
options.cinematic = $("cinematic").checked;
updatePose();
});
for (const view of Object.keys(debug)) {
$(`debug-${view}`).addEventListener("change", () => {
const wasVisible = hasDebug();
debug[view] = $(`debug-${view}`).checked;
if (instance && wasVisible !== hasDebug()) setDebugCharacter(ecd, instance, hasDebug());
});
}
engine.graphics.on.preRender.add(() => {
if (hasDebug() && instance) drawClothDebug(ecd, instance, cloths, debug);
});
engine.graphics.on.postRender.add(() => sopra?.update());
function restoreSecondaryPose() {
for (const { entity, local, rest } of secondaryPoses) {
local.copy(rest);
ecd.sendEvent(entity, TRANSFORM_ATTACHMENT_EVENT_CHANGE);
}
}
function resetSecondaryPhysics() {
for (const { entity } of cloths) ecd.removeComponentFromEntity(entity, Cloth);
restoreSecondaryPose();
for (const { entity, component } of cloths) ecd.addComponentToEntity(entity, component);
}
// The AudioContext clock is the only time source. Pausing captures its cursor;
// resuming rebases the event's startTime, so frame pacing cannot accumulate drift.
export class DanceTransport {
playing = false;
held = 0;
startedAt = 0;
seconds(now) {
return this.playing ? Math.max(this.held, now - this.startedAt) : this.held;
}
play(now) {
this.startedAt = now - this.held;
this.playing = true;
return this.startedAt;
}
pause(now) {
this.held = this.seconds(now);
this.playing = false;
}
restart(now) {
this.held = 0;
this.startedAt = now;
}
}
# Original three.js MMD asset credits and terms
These assets are **not covered by three.js's MIT license**. Model, character,
motion and audio have separate rights. Publication and redistribution of this
example require the corresponding rights holders' permission for the intended
use; the publisher has confirmed that permission. This does not grant downstream
users a general commercial-use license.
## Character and model
**Hatsune Miku © Crypton Future Media, INC.** Model and data conversion by
**Animasa**: the original `miku_v2.pmd` bundled with MikuMikuDance, version 2.3.
- Character guidelines: https://piapro.net/intl/en_for_creators.html
- Original MMD distribution: https://sites.google.com/view/evpvp/
- Three.js asset provenance: https://github.com/mrdoob/three.js/tree/r158/examples/models/mmd
The character guidelines do not grant a blanket license for music or third-party
3D models. Read the original asset readmes in `public/assets/credits/`.
Camera motion also comes from three.js's `wavefile_camera.vmd`, converted offline
to sampled position, target, up-vector and field-of-view tracks.
Converted offline: standard glTF skin and PBR materials, uniform metre scale,
BMP eye texture converted to PNG, facial morph targets omitted. Native skeleton,
mesh topology and body skin weights are preserved. Each pigtail is refined from
six to twelve segments and rebound to the added midpoint joints, preserving its
original bind-pose geometry. Skirt weights blend from hips into thigh animation,
retaining a restrained cloth contribution. The hem is extended by up to 3.5 cm
and slightly flared. Skirt and tie joint pivots are
adapted to their authored physics body centres, with inverse binds updated to
preserve the original rest geometry.
The overlapping front dress and skirt are rebound to two added four-joint cloth
chains, with their influence reduced to stabilize coverage during movement.
Frill bindings are transferred from the dress surface, with matching weights at
shared seam positions. The front clothing layers share a longitudinal binding.
## Motion
**WAVEFILE**, by **hino**, traced from **かんな@むーみん**'s dance. Original
`wavefile_v2.vmd`, the motion adjusted for standard Miku version 2.
- Motion release: https://www.nicovideo.jp/watch/sm13147122
- Traced dance: https://www.nicovideo.jp/watch/sm12016176
The motion readme permits modification and redistribution, and prohibits
unauthorized commercial use. See `credits/readme_wavefile.txt` for its original
conditions. Converted to glTF with original timing and interpolation; constrained
limb guide frames derived offline and applied as animation in Meep.
## Music
**WAVEFILE / 初音ミク (short version)**, by **ラマーズP (LamazeP)**.
The original `wavefile_short.mp3` is included unmodified.
- Song: https://www.nicovideo.jp/watch/sm11938255
- Creator's profile and usage terms: https://piapro.jp/shine_longer
- Song page: https://piapro.jp/t/T2Ol
The included readme allows noncommercial use and requires contacting the creator
for commercial use. See `credits/audio-readme.txt`. The original example's
160-frame music delay is preserved.
Original asset files were taken from the local three.js checkout's
`examples/models/mmd/` directory. Original readmes are retained with the assets;
they are not replaced by this summary.
<!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>Dance stage · 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; background: #080c16de; border: 1px solid #29344b; border-radius: 14px; padding: 18px; backdrop-filter: blur(12px); }
p { color: #a8b6cd; line-height: 1.5; margin: 8px 0; }
.controls { bottom: 20px; left: 20px; width: 300px; }
button { background: #23314a; color: inherit; border: 1px solid #465675; padding: 9px 15px; border-radius: 8px; cursor: pointer; }
button.primary { background: #6bedc2; color: #062019; border-color: #6bedc2; font-weight: 600; }
#play.awaiting-play:not(:disabled) { animation: play-pulse 1.8s ease-in-out infinite; }
@keyframes play-pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 #6bedc200; }
45% { transform: scale(1.06); box-shadow: 0 0 0 7px #6bedc23d; }
75% { box-shadow: 0 0 0 12px #6bedc200; }
}
@media (prefers-reduced-motion: reduce) {
#play.awaiting-play:not(:disabled) { animation: none; box-shadow: 0 0 0 3px #6bedc266; }
}
button:disabled { opacity: .45; cursor: wait; }
button:focus-visible, input:focus-visible { outline: 2px solid #6bedc2; outline-offset: 3px; }
.row { display: flex; gap: 8px; align-items: center; margin: 10px 0; }
label { display: flex; gap: 8px; align-items: center; }
input { accent-color: #6bedc2; }
input[type=range] { width: 125px; }
progress { display: block; width: 100%; height: 3px; appearance: none; -webkit-appearance: none; border: 0; outline: 0; box-shadow: none; background: #23314a; border-radius: 2px; overflow: hidden; }
progress::-webkit-progress-bar { background: #23314a; border: 0; box-shadow: none; }
progress::-webkit-progress-value { background: #6bedc2; }
progress::-moz-progress-bar { background: #6bedc2; }
.credits { right: 20px; bottom: 20px; max-width: 265px; color: #a8b6cd; font-size: 11px; line-height: 1.7; }
a { color: #b7d6ff; }
#status { font-size: 12px; min-height: 18px; }
.icon { padding: 7px; display: grid; place-items: center; }
.icon svg { width: 20px; height: 20px; }
#mute { margin-left: auto; }
.icon .muted { display: none; }
.icon[aria-pressed=true] .waves { display: none; }
.icon[aria-pressed=true] .muted { display: block; }
@media (max-width: 650px) { .controls { left: 10px; bottom: 10px; width: 275px; padding: 12px; } .credits { display: none; } }
</style>
</head>
<body>
<section class="panel controls" aria-label="Dance controls">
<div class="row"><button id="play" class="primary awaiting-play" disabled>Play</button><button id="restart" class="icon" type="button" aria-label="Rewind to start" title="Rewind" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m11 19-9-7 9-7v14Zm11 0-9-7 9-7v14Z"/></svg></button><button id="mute" class="icon" type="button" aria-label="Mute" title="Mute" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4Z"/><g class="waves"><path d="M15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14"/></g><g class="muted"><path d="m16 9 6 6m0-6-6 6"/></g></svg></button></div>
<progress id="progress" value="0" max="1" aria-label="Dance phrase progress"></progress>
<div class="row"><label><input id="physics" type="checkbox" checked>Cloth simulation</label></div>
<div class="row"><label><input id="cinematic" type="checkbox" checked disabled>Cinematic camera</label></div>
<div class="row" role="group" aria-label="Visualization">
<label style="color:#61f5c4"><input id="debug-cloth" type="checkbox">Cloth</label>
<label style="color:#ffae61"><input id="debug-colliders" type="checkbox">Colliders</label>
<label style="color:#76b5ff"><input id="debug-clearance" type="checkbox">Clearance</label>
</div>
<p id="status" role="status">Loading…</p>
</section>
<aside class="panel credits">Hatsune Miku © Crypton Future Media, INC.<br>Model: Animasa · Dance: <a href="https://www.nicovideo.jp/watch/sm13147122" target="_blank" rel="noopener">hino</a><br>WAVEFILE: <a href="https://www.nicovideo.jp/watch/sm11938255" target="_blank" rel="noopener">LamazeP</a></aside>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Dance stage",
"description": "Hatsune Miku dancing to WAVEFILE, with physical twin tails and clothing.",
"category": "Animation",
"status": "live",
"order": 20,
"tags": ["animation", "skinning", "cloth", "physics", "audio", "glTF"],
"demoUrl": "/examples/dance-stage/demo.html"
}
{
"name": "@meep-examples/dance-stage",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A dancing character with synchronized audio and secondary cloth physics.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "3.41.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Dance stage
Hatsune Miku dancing to WAVEFILE, using the matched character, motion and song
from three.js's MMD audio example. Runtime rendering, pose evaluation, cloth and
audio use Meep 3.41.0.
## Run
Requires Node 24+ and a WebGPU browser supporting WGSL `immediate_address_space`.
```sh
npm install
npm run dev
```
Open `/demo.html`. Play/Pause, the rewind button, the speaker button and Cloth simulation
control playback. Cinematic camera follows the original camera track; dragging
or scrolling returns control to you from the current view. Independent Cloth, Colliders and Clearance checkboxes show green cloth
joints/links, orange body/floor volumes and blue hair-clearance volumes using
Meep's Gizmo and DebugDrawSystem. Selecting any view makes the character
semi-transparent. The views work while paused and with simulation disabled.
`npm run build` emits into `public/examples/dance-stage/`. Assets are bundled;
no third-party asset requests are made. See [ASSET-LICENSES.md](ASSET-LICENSES.md)
before publishing or redistributing them.
## Converted assets
The original PMD body rig, body skin weights, material groups and eye
texture are retained. Geometry and translations are scaled by 0.08 into metres.
Bone tracks are sampled directly from their MMD interpolants at 60 Hz, including
held keys. The original 93.633-second motion is neither retargeted nor retimed.
The original camera's position, target, up vector and field of view are sampled
offline at 60 Hz, with separate shots preserving its cuts. Meep animation curves
and the native camera controller play these on the same audio clock as the dancer.
MMD's constrained limb frames are converted offline and applied as animation,
preserving the original knees and limb twist. There is no runtime foot IK or
foot-lift control. No MMD parser, three.js or Bullet code ships at runtime.
Facial morphs are omitted because Shade does not support morph targets. Materials
use standard PBR rather than MMD toon shading and outlines.
Each pigtail's six longitudinal segments are split into twelve, adding 12 joints
to the native 140-joint rig. Hair is rebound to the finer chains; head blends are
retained. Skirt and tie pivots are adapted to their authored physics body centres
with matching inverse binds, preserving the original resting geometry.
The skirt's upper band follows the hips. Below it, weights blend into thigh
animation to keep the skirt with the legs during forward steps. The cloth share
is limited to 8% of its previous influence. The lower hem is extended by up to
3.5 cm and flared slightly for coverage. Mesh topology is unchanged.
Two four-joint cloth chains support the front clothing over the thighs. The
overlapping dress and skirt share the stabilized hip/thigh binding and retain
a small contribution from the front cloth chains. Eight additional
joints bring the refined skin to 160 joints; the original resting geometry is retained.
Frills inherit weights from the nearest dress triangle. Shared seam positions
use exactly the same binding, so the edging stays sewn to its fabric.
## Runtime
MeshSystem loads glTF skinning and joint entities. pose_evaluate_local evaluates
body animation on the CPU; GPU authority is revoked on the offsets that it
writes, and GPU skinning consumes the resulting pose.
ClothRig and cloth_proxy_from_joints drive four disjoint garments: hair, skirt,
front flaps and tie. Separate component entities use ClothWorld's documented model-instance
provider to address the same drawn instance. Simulation runs at 120 Hz after
posing its anchors. Sleeves retain their original animation.
The 13 body collision volumes use the authored limb offsets, with torso and
thigh capsules fitted to the mesh. Redundant chest spheres and thigh boxes are omitted.
Arms and forearms have 4 cm of hair-only clearance; thighs and shins have 8 cm.
The stage and surrounding floor have box proxies with extra hair
clearance. Collision masks keep that padding from displacing the tie and skirt.
Meep's self-collision is local to each cloth instance. The overlapping front
clothing layers use a shared deformation field to preserve their separation.
These are sparse joint-particle collisions, not triangle-perfect cloth, and do
not reproduce all 45 original Bullet bodies and 27 joints.
The joint proxy does not constrain rendered faces between particles. The skirt's
animation-dominant binding limits the effect of those unconstrained spans.
Sopra plays the unmodified MP3 with the original 160-frame / 5⅓-second cue offset.
Animation uses AudioContext.getOutputTimestamp projected to the current frame,
so it follows the sample leaving the output device rather than the ahead-of-output
render clock. A baseLatency/outputLatency fallback covers unavailable timestamps.
Pause captures the audible cursor; resume holds the pose until that cursor reaches
the device, avoiding a rewind. Each cycle lasts the longer of the animation or
delayed audio, plus the original helper's two-second afterglow.
WorkerClothSystem runs the cloth solve in Meep's module worker using shared
memory. Pose evaluation, collider gathering and completed-pose write-back remain
on the main thread. Cloth results arrive one fixed tick later.
Vite dev/preview supplies cross-origin isolation headers. A production static
host must send these headers on the demo and its embedding page (or the whole
site), too. For nginx, add within the serving configuration:
```nginx
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
```
There is no synchronous fallback: the demo requires worker cloth.
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));
const isolationHeaders = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
export default defineConfig({
root: __dirname,
publicDir: resolve(__dirname, "public"),
server: { headers: isolationHeaders },
preview: { headers: isolationHeaders },
// Vite's dependency pre-bundling rewrites the engine's own asset imports and
// loses their URLs, so a dev server ends up serving ".../deps/undefined" for
// the renderer's data files. The engine is source-only ESM and needs no
// pre-bundling - but its CommonJS dependencies do, or the import of them from inside
// the excluded engine finds no default export.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/dance-stage");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/dance-stage"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip({ functions: ["assert.*", "console.log", "console.debug", "console.time", "console.timeEnd"] }),
apply: 'build'
}
],
},
target: "es2022",
},
});
thumbnail.png · 183.6 KB
audio-readme.txt
Unsupported file type.
readme_miku_v2.txt
Unsupported file type.
readme_wavefile.txt
Unsupported file type.
THREE-ASSET-LICENSE.txt
Unsupported file type.
THREE-ASSET-README.txt
Unsupported file type.
camera-motion.bin
Binary file.
miku-wavefile.glb
Binary file.
wavefile_short.mp3
Binary file.