ifire's picture
Initial mirror of localai-org/motion-bricks.cpp@6fdb75e
d456972 verified
Raw History Blame Contribute Delete
21.2 kB
import * as THREE from './vendor/three.module.min.js';
const viewport = document.querySelector('#viewport');
const styleSelect = document.querySelector('#style-select');
const statusElement = document.querySelector('#status');
const planInfo = document.querySelector('#plan-info');
const targetInfo = document.querySelector('#target-info');
const testResult = document.querySelector('#test-result');
const showAllTargets = document.querySelector('#show-all-targets');
const targetFrame = document.querySelector('#target-frame');
const targetFrameLabel = document.querySelector('#target-frame-label');
const resetCamera = document.querySelector('#reset-camera');
const query = new URLSearchParams(location.search);
const state = {
meta: null, session: '', motion: null, targets: null, playhead: 0,
lastTime: performance.now(), move: [0, 0], facing: [0, 1], keys: new Set(),
padKey: '', pending: false, replanQueued: false, seed: 10, style: '', rig: null, targetRigs: [],
generatedPath: null, targetPath: null,
};
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x090b10, 0.045);
const camera = new THREE.PerspectiveCamera(42, 1, 0.02, 100);
const renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
viewport.append(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xcde4ff, 0x253226, 2.5));
const keyLight = new THREE.DirectionalLight(0xffffff, 3.2);
keyLight.position.set(3, 7, 4);
keyLight.castShadow = true;
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x67e8c3, 1.2);
rimLight.position.set(-4, 3, -3);
scene.add(rimLight);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(40, 40),
new THREE.MeshStandardMaterial({color: 0x0d121a, roughness: 0.96, metalness: 0.02}),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.012;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(24, 48, 0x3a4b5d, 0x202b38);
grid.position.y = 0;
scene.add(grid);
const cylinderGeometry = new THREE.CylinderGeometry(1, 1, 1, 8, 1, false);
const sphereGeometry = new THREE.SphereGeometry(1, 14, 10);
const diamondGeometry = new THREE.OctahedronGeometry(1, 0);
const up = new THREE.Vector3(0, 1, 0);
const startPoint = new THREE.Vector3();
const endPoint = new THREE.Vector3();
const direction = new THREE.Vector3();
const midpoint = new THREE.Vector3();
function labelSprite(text, color, opacity) {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 64;
const context = canvas.getContext('2d');
context.font = '700 30px system-ui';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = color;
context.fillText(text, 64, 32);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({map: texture, transparent: true, opacity, depthTest: false}));
sprite.scale.set(0.32, 0.16, 1);
sprite.renderOrder = 20;
return sprite;
}
class SkeletonRig {
constructor(joints, options) {
this.bones = [];
this.jointMeshes = [];
this.segments = [];
this.group = new THREE.Group();
this.radius = options.radius;
this.jointRadius = options.jointRadius;
this.rootRadius = options.rootRadius;
this.labelHeight = options.labelHeight ?? 0.34;
this.boneMaterial = new THREE.MeshStandardMaterial({
color: options.color, emissive: options.emissive, emissiveIntensity: options.emissiveIntensity,
roughness: 0.42, transparent: options.opacity < 1, opacity: options.opacity,
depthWrite: options.opacity >= 0.95,
});
this.jointMaterial = new THREE.MeshStandardMaterial({
color: options.jointColor, emissive: options.emissive, emissiveIntensity: options.emissiveIntensity,
roughness: 0.35, transparent: options.opacity < 1, opacity: options.opacity,
depthWrite: options.opacity >= 0.95,
});
this.rootMaterial = new THREE.MeshStandardMaterial({
color: options.rootColor, emissive: options.rootEmissive, emissiveIntensity: 1.4,
transparent: options.opacity < 1, opacity: options.opacity, depthWrite: options.opacity >= 0.95,
});
for (let index = 0; index < joints.length; index++) {
const joint = joints[index];
const bone = new THREE.Bone();
bone.name = joint.name;
if (joint.parent < 0) {
bone.position.set(0, 0, 0);
} else {
const parent = joints[joint.parent].position;
bone.position.set(joint.position[0] - parent[0], joint.position[1] - parent[1], joint.position[2] - parent[2]);
this.bones[joint.parent].add(bone);
}
this.bones.push(bone);
const geometry = options.diamonds ? diamondGeometry : sphereGeometry;
const marker = new THREE.Mesh(geometry, index === 0 ? this.rootMaterial : this.jointMaterial);
marker.castShadow = !options.diamonds;
marker.renderOrder = options.renderOrder;
this.jointMeshes.push(marker);
this.group.add(marker);
if (joint.parent >= 0) {
const segment = new THREE.Mesh(cylinderGeometry, this.boneMaterial);
segment.castShadow = !options.diamonds;
segment.renderOrder = options.renderOrder;
this.segments.push({mesh: segment, child: index, parent: joint.parent});
this.group.add(segment);
}
}
scene.add(this.bones[0]);
scene.add(this.group);
this.label = options.label ? labelSprite(options.label, options.labelColor, options.opacity) : null;
if (this.label) scene.add(this.label);
}
pose(roots, rotations, frame, frameCount) {
const index = Math.min(frameCount - 1, Math.max(0, frame));
this.bones[0].position.fromArray(roots, index * 3);
for (let joint = 0; joint < this.bones.length; joint++) {
this.bones[joint].quaternion.fromArray(rotations, (index * this.bones.length + joint) * 4);
}
this.updateGeometry();
}
updateGeometry() {
this.bones[0].updateMatrixWorld(true);
for (let index = 0; index < this.bones.length; index++) {
this.bones[index].getWorldPosition(endPoint);
const marker = this.jointMeshes[index];
marker.position.copy(endPoint);
const radius = index === 0 ? this.rootRadius : this.jointRadius;
marker.scale.setScalar(radius);
}
for (const segment of this.segments) {
this.bones[segment.parent].getWorldPosition(startPoint);
this.bones[segment.child].getWorldPosition(endPoint);
direction.subVectors(endPoint, startPoint);
const length = direction.length();
midpoint.addVectors(startPoint, endPoint).multiplyScalar(0.5);
segment.mesh.position.copy(midpoint);
segment.mesh.quaternion.setFromUnitVectors(up, direction.normalize());
segment.mesh.scale.set(this.radius, length, this.radius);
}
if (this.label) {
this.bones[0].getWorldPosition(endPoint);
this.label.position.set(endPoint.x, endPoint.y + this.labelHeight, endPoint.z);
}
}
setVisible(visible) {
this.group.visible = visible;
this.bones[0].visible = visible;
if (this.label) this.label.visible = visible;
}
setOpacity(opacity) {
for (const material of [this.boneMaterial, this.jointMaterial, this.rootMaterial]) {
material.opacity = opacity;
material.transparent = opacity < 1;
material.depthWrite = opacity >= 0.95;
}
if (this.label) this.label.material.opacity = opacity;
}
}
function makeLine(color, dashed = false) {
const material = dashed
? new THREE.LineDashedMaterial({color, dashSize: 0.09, gapSize: 0.055, transparent: true, opacity: 0.9})
: new THREE.LineBasicMaterial({color, transparent: true, opacity: 0.72});
const line = new THREE.Line(new THREE.BufferGeometry(), material);
line.renderOrder = 1;
scene.add(line);
return line;
}
function setGroundPath(line, roots, frames) {
const points = [];
for (let frame = 0; frame < frames; frame++) {
points.push(new THREE.Vector3(roots[frame * 3], 0.018, roots[frame * 3 + 2]));
}
line.geometry.dispose();
line.geometry = new THREE.BufferGeometry().setFromPoints(points);
if (line.material.isLineDashedMaterial) line.computeLineDistances();
}
function makeSkeletons(joints) {
state.rig = new SkeletonRig(joints, {
color: 0x55efc4, jointColor: 0xd9fff3, rootColor: 0xffd166,
emissive: 0x0c5b49, rootEmissive: 0x6a3b00, emissiveIntensity: 0.8,
radius: 0.022, jointRadius: 0.034, rootRadius: 0.062,
opacity: 1, diamonds: false, renderOrder: 5,
});
for (let frame = 0; frame < 4; frame++) {
state.targetRigs.push(new SkeletonRig(joints, {
color: 0xff763b, jointColor: 0xffb06b, rootColor: 0xff4f8b,
emissive: 0x7b1f00, rootEmissive: 0x790025, emissiveIntensity: 1,
radius: 0.018, jointRadius: 0.032, rootRadius: 0.056, opacity: 0.9, diamonds: true,
renderOrder: 8 + frame, label: `T${frame}`, labelColor: frame === 3 ? '#ffb477' : '#c97354',
labelHeight: 0.34 + (3 - frame) * 0.11,
}));
}
state.generatedPath = makeLine(0x43e8bf);
state.targetPath = makeLine(0xff6b3d, true);
}
function updateTargetVisibility() {
const frames = state.targets?.frames ?? 0;
const maximum = Math.max(0, frames - 1);
targetFrame.max = String(maximum);
const selected = THREE.MathUtils.clamp(Number(targetFrame.value), 0, maximum);
targetFrame.value = String(selected);
targetFrameLabel.textContent = `T${selected}`;
const overlay = showAllTargets.checked;
const overlayOpacities = [0.16, 0.25, 0.42, 0.9];
for (let frame = 0; frame < state.targetRigs.length; frame++) {
const exists = frame < frames;
state.targetRigs[frame].setOpacity(overlay ? overlayOpacities[frame] : 0.9);
state.targetRigs[frame].setVisible(exists && (overlay || frame === selected));
}
state.targetPath.visible = frames > 0;
targetInfo.textContent = frames > 0
? (overlay ? `${frames} consecutive constraints overlaid` : `T${selected} of ${frames} consecutive constraints`)
: '—';
}
const cameraView = {yaw: 0.68, pitch: 0.24, distance: 4.8, dragging: false, x: 0, y: 0};
const focus = new THREE.Vector3();
const desiredCamera = new THREE.Vector3();
const visibleBounds = new THREE.Box3();
function resetCameraView() {
cameraView.yaw = 0.68;
cameraView.pitch = 0.24;
cameraView.distance = 4.8;
}
resetCamera.addEventListener('click', resetCameraView);
renderer.domElement.addEventListener('pointerdown', event => {
cameraView.dragging = true; cameraView.x = event.clientX; cameraView.y = event.clientY;
renderer.domElement.setPointerCapture(event.pointerId);
});
renderer.domElement.addEventListener('pointermove', event => {
if (!cameraView.dragging) return;
cameraView.yaw -= (event.clientX - cameraView.x) * 0.006;
cameraView.pitch = THREE.MathUtils.clamp(cameraView.pitch + (event.clientY - cameraView.y) * 0.004, -0.05, 1.05);
cameraView.x = event.clientX; cameraView.y = event.clientY;
});
renderer.domElement.addEventListener('pointerup', event => {
cameraView.dragging = false; renderer.domElement.releasePointerCapture(event.pointerId);
});
renderer.domElement.addEventListener('pointercancel', () => { cameraView.dragging = false; });
renderer.domElement.addEventListener('wheel', event => {
cameraView.distance = THREE.MathUtils.clamp(cameraView.distance * Math.exp(event.deltaY * 0.001), 2.1, 8);
event.preventDefault();
}, {passive: false});
renderer.domElement.addEventListener('dblclick', resetCameraView);
function resize() {
const width = Math.max(1, viewport.clientWidth), height = Math.max(1, viewport.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
addEventListener('resize', resize);
resize();
async function api(path, body) {
const response = await fetch(path, {
method: body ? 'POST' : 'GET', headers: {'Content-Type': 'application/json'},
body: body ? JSON.stringify(body) : undefined,
});
const value = await response.json();
if (!response.ok) throw new Error(value.error || `${response.status} ${response.statusText}`);
return value;
}
function installStyles(styles) {
for (const style of styles) {
const option = document.createElement('option');
option.value = style.name;
option.textContent = `${style.name.replaceAll('_', ' ')} · ${style.speed.toFixed(1)} m/s`;
styleSelect.append(option);
}
state.style = styles.some(item => item.name === 'walk') ? 'walk' : styles[0].name;
styleSelect.value = state.style;
styleSelect.addEventListener('change', () => { state.style = styleSelect.value; void requestPlan(); });
}
function useMotion(response, plannedMove = state.move) {
state.session = response.session;
state.style = response.style;
styleSelect.value = response.style;
state.motion = response.motion;
state.targets = response.targets;
state.playhead = 0;
state.lastTime = performance.now();
planInfo.textContent = `${response.motion.frames} frames · ${response.style.replaceAll('_', ' ')}`;
statusElement.textContent = 'Playing';
document.documentElement.dataset.planSequence = String(Number(document.documentElement.dataset.planSequence || 0) + 1);
document.documentElement.dataset.plannedMoveX = String(plannedMove[0]);
document.documentElement.dataset.plannedMoveZ = String(plannedMove[1]);
setGroundPath(state.generatedPath, response.motion.roots, response.motion.frames);
setGroundPath(state.targetPath, response.targets.roots, response.targets.frames);
for (let frame = 0; frame < response.targets.frames; frame++) {
state.targetRigs[frame].pose(response.targets.roots, response.targets.rotations, frame, response.targets.frames);
}
updateTargetVisibility();
}
async function requestPlan(advance = Math.floor(state.playhead)) {
if (!state.session) return null;
if (state.pending) {
state.replanQueued = true;
return null;
}
state.pending = true;
statusElement.textContent = 'Planning…';
const plannedMove = [...state.move];
try {
const response = await api('/api/plan', {
session: state.session, style: state.style, move: state.move, facing: state.facing,
seed: state.seed++, advance,
});
useMotion(response, plannedMove);
return response;
} finally {
state.pending = false;
if (state.replanQueued) {
state.replanQueued = false;
queueMicrotask(() => void requestPlan(Math.floor(state.playhead)));
}
}
}
function updateControl() {
let x = 0, z = 0;
const active = key => state.keys.has(key) || state.padKey === key;
if (active('w')) z += 1;
if (active('s')) z -= 1;
if (active('a')) x -= 1;
if (active('d')) x += 1;
const length = Math.hypot(x, z);
if (length > 0) { x /= length; z /= length; state.facing = [x, z]; }
state.move = [x, z];
document.querySelectorAll('.pad button').forEach(button => {
const pressed = active(button.dataset.key);
button.classList.toggle('active', pressed);
button.setAttribute('aria-pressed', String(pressed));
});
}
let controlTimer = 0;
function schedulePlan(delay = 0) { clearTimeout(controlTimer); controlTimer = setTimeout(() => void requestPlan(), delay); }
addEventListener('keydown', event => {
const key = event.key.toLowerCase();
if (['w', 'a', 's', 'd'].includes(key) && !state.keys.has(key)) {
state.keys.add(key); updateControl(); schedulePlan(); event.preventDefault();
}
if (event.code === 'Space' || event.key === 'Escape') {
state.keys.clear(); state.padKey = ''; updateControl(); schedulePlan(); event.preventDefault();
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const angle = Math.atan2(state.facing[0], state.facing[1]) + (event.key === 'ArrowLeft' ? 0.25 : -0.25);
state.facing = [Math.sin(angle), Math.cos(angle)]; schedulePlan(35); event.preventDefault();
}
});
addEventListener('keyup', event => {
const key = event.key.toLowerCase();
if (state.keys.delete(key)) { updateControl(); schedulePlan(); }
});
for (const button of document.querySelectorAll('.pad button')) {
button.setAttribute('aria-pressed', 'false');
button.addEventListener('click', event => {
state.padKey = state.padKey === button.dataset.key ? '' : button.dataset.key;
updateControl();
schedulePlan();
event.preventDefault();
});
}
showAllTargets.addEventListener('change', updateTargetVisibility);
targetFrame.addEventListener('input', updateTargetVisibility);
function updateCamera() {
visibleBounds.makeEmpty();
for (const marker of state.rig.jointMeshes) visibleBounds.expandByPoint(marker.position);
visibleBounds.getCenter(focus);
const horizontal = Math.cos(cameraView.pitch) * cameraView.distance;
desiredCamera.set(
focus.x + Math.sin(cameraView.yaw) * horizontal,
focus.y + Math.sin(cameraView.pitch) * cameraView.distance,
focus.z + Math.cos(cameraView.yaw) * horizontal,
);
if (camera.userData.positioned) camera.position.lerp(desiredCamera, 0.12);
else { camera.position.copy(desiredCamera); camera.userData.positioned = true; }
camera.lookAt(focus);
}
function renderMotion(frame) {
if (!state.motion) return;
const index = Math.min(state.motion.frames - 1, Math.max(0, frame));
state.rig.pose(state.motion.roots, state.motion.rotations, index, state.motion.frames);
updateCamera();
}
function animate(now) {
requestAnimationFrame(animate);
const delta = Math.min(0.1, (now - state.lastTime) / 1000);
state.lastTime = now;
if (state.motion) {
state.playhead += delta * state.meta.fps;
if (state.playhead >= state.motion.frames - 5 && !state.pending) void requestPlan(Math.floor(state.playhead));
renderMotion(Math.floor(state.playhead));
}
renderer.render(scene, camera);
}
async function selfTest() {
const alternate = state.meta.styles.find(item => item.name === 'walk_zombie') || state.meta.styles.find(item => item.name !== 'walk');
if (!alternate) throw new Error('no alternate upstream style available');
state.style = alternate.name;
styleSelect.value = alternate.name;
state.move = [1, 0];
state.facing = [1, 0];
const response = await requestPlan(3);
if (!response || response.style !== alternate.name) throw new Error('style change was not applied');
if (response.motion.joints !== 34 || response.motion.frames < 24 || response.motion.rotations.length !== response.motion.frames * 34 * 4) {
throw new Error('invalid skeletal animation response');
}
if (response.targets.frames !== 4 || response.targets.joints !== 34 || response.targets.roots.length !== 12 || response.targets.rotations.length !== 4 * 34 * 4) {
throw new Error('invalid placed target-keyframe response');
}
renderMotion(2);
renderer.render(scene, camera);
let visibleTargets = state.targetRigs.filter(rig => rig.group.visible).length;
if (!renderer.domElement.width || state.rig.bones.length !== 34 || visibleTargets !== 1 || !state.targetRigs[3].group.visible) {
throw new Error('animated and target skeletons were not rendered');
}
const expectedFocus = new THREE.Vector3();
const animatedBounds = new THREE.Box3();
for (const marker of state.rig.jointMeshes) animatedBounds.expandByPoint(marker.position);
animatedBounds.getCenter(expectedFocus);
if (focus.distanceTo(expectedFocus) > 1e-6) throw new Error('camera is not anchored to the animated skeleton');
targetFrame.value = '1';
updateTargetVisibility();
if (!state.targetRigs[1].group.visible || state.targetRigs.filter(rig => rig.group.visible).length !== 1) {
throw new Error('target-frame inspector did not select T1');
}
showAllTargets.checked = true;
updateTargetVisibility();
visibleTargets = state.targetRigs.filter(rig => rig.group.visible).length;
if (visibleTargets !== 4) throw new Error('four-pose target overlay did not render');
showAllTargets.checked = false;
targetFrame.value = '3';
updateTargetVisibility();
document.documentElement.dataset.animatedJoints = String(state.rig.bones.length);
document.documentElement.dataset.targetFrames = String(response.targets.frames);
document.documentElement.dataset.visibleTargets = '1';
document.documentElement.dataset.cameraSubject = 'animated';
document.documentElement.dataset.testStatus = 'passed';
testResult.textContent = `Headless check passed: animated-camera anchor + target inspector/overlay, ${alternate.name}, right turn`;
}
async function start() {
try {
state.meta = await api('/api/meta');
installStyles(state.meta.styles);
makeSkeletons(state.meta.joints);
const initial = await api('/api/session', {style: state.style});
useMotion(initial);
renderMotion(0);
requestAnimationFrame(animate);
if (query.get('test') === '1') await selfTest();
else document.documentElement.dataset.testStatus = 'ready';
} catch (error) {
console.error(error);
statusElement.textContent = 'Error';
testResult.textContent = error.message;
document.documentElement.dataset.testStatus = 'failed';
}
}
await start();