import * as THREE from 'three';
import { OrbitControls } from './vendor/OrbitControls.js';
import { Simulation, MATERIALS } from './mpm.js';
import { makeAsset, SHAPE_LIST } from './assets.js';
const canvas = document.getElementById('view');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#10151b');
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100);
camera.position.set(1.5, 1.05, 1.6);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.target.set(0.5, 0.35, 0.5);
scene.add(new THREE.HemisphereLight('#cfe0f0', '#20262e', 1.3));
const key = new THREE.DirectionalLight('#fff4e2', 1.6);
key.position.set(2, 3, 1.5);
scene.add(key);
const rim = new THREE.DirectionalLight('#6ea8d8', 0.7);
rim.position.set(-2, 1.2, -1.5);
scene.add(rim);
// the unit box the solver works in
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2).translate(0.5, 0, 0.5),
new THREE.MeshStandardMaterial({ color: '#2b333d', roughness: 0.95 })
);
scene.add(floor);
const cage = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1).translate(0.5, 0.5, 0.5)),
new THREE.LineBasicMaterial({ color: '#33414f' })
);
scene.add(cage);
// ------------------------------------------------------------------ kernels ---
// One instanced ellipsoid per gaussian. A unit sphere scaled by the transformed
// axes is exactly what Σ' = F Σ Fᵀ describes, so the deformation is visible in
// the shape of each kernel and not only in where it moved.
let sim = null, asset = null, mesh = null;
const dummy = new THREE.Object3D();
const mat3 = new THREE.Matrix4();
function buildMesh(n, colors) {
if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); }
mesh = new THREE.InstancedMesh(
new THREE.SphereGeometry(1, 6, 4),
new THREE.MeshStandardMaterial({ roughness: 0.55, metalness: 0.05, vertexColors: false }),
n
);
mesh.instanceColor = new THREE.InstancedBufferAttribute(Float32Array.from(colors), 3);
mesh.frustumCulled = false;
scene.add(mesh);
}
function syncMesh() {
const { x, sigma, rot, n } = sim;
for (let i = 0; i < n; i++) {
// columns of F scaled by sigma give the transformed principal axes
const f = rot.subarray(i * 9, i * 9 + 9);
const sx = sigma[i * 3], sy = sigma[i * 3 + 1], sz = sigma[i * 3 + 2];
mat3.set(
f[0] * sx, f[1] * sy, f[2] * sz, x[i * 3],
f[3] * sx, f[4] * sy, f[5] * sz, x[i * 3 + 1],
f[6] * sx, f[7] * sy, f[8] * sz, x[i * 3 + 2],
0, 0, 0, 1
);
mesh.setMatrixAt(i, mat3);
}
mesh.instanceMatrix.needsUpdate = true;
}
// --------------------------------------------------------------------- ui -----
const shapeSel = document.getElementById('shape');
const matSel = document.getElementById('material');
const countEl = document.getElementById('count');
const statsEl = document.getElementById('stats');
shapeSel.innerHTML = SHAPE_LIST.map(s => ``).join('');
matSel.innerHTML = Object.entries(MATERIALS)
.map(([id, m]) => ``).join('');
let running = false;
// Explicit MPM needs many small steps, but each one is a full P2G/G2P sweep. Eight
// keeps a soft material stable while leaving the frame budget usable.
let substeps = 8;
function rebuild() {
const n = parseInt(countEl.value, 10) || 4000;
asset = makeAsset(shapeSel.value, n, 7);
sim = new Simulation(asset.positions, asset.sigma, matSel.value);
buildMesh(asset.count, asset.color);
syncMesh();
report(0);
}
function report(ms) {
const m = MATERIALS[sim.matName];
statsEl.textContent =
`${asset.count.toLocaleString()} kernels · ${m.label} · E=${m.E.toExponential(1)} · ` +
`t=${sim.time.toFixed(2)}s${ms ? ` · ${ms.toFixed(1)} ms/frame` : ''}`;
}
document.getElementById('go').onclick = () => {
running = !running;
document.getElementById('go').textContent = running ? 'Pause' : 'Drop';
document.getElementById('go').classList.toggle('on', running);
};
document.getElementById('reset').onclick = () => {
running = false;
document.getElementById('go').textContent = 'Drop';
document.getElementById('go').classList.remove('on');
rebuild();
};
shapeSel.onchange = () => { running = false; document.getElementById('go').textContent = 'Drop'; rebuild(); };
matSel.onchange = () => {
sim.setMaterial(matSel.value);
report(0);
};
countEl.onchange = () => { running = false; rebuild(); };
// ------------------------------------------------------------------- loop -----
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (canvas.width !== w || canvas.height !== h) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
}
let frameMs = 0;
function tick() {
if (!sim || !running) return;
const t0 = performance.now();
// Many small steps: MPM is explicit, so stability is set by the step size and
// the stiffest material has to survive it.
const dt = 2.4e-4;
for (let s = 0; s < substeps; s++) sim.step(dt);
syncMesh();
frameMs = frameMs * 0.85 + (performance.now() - t0) * 0.15;
report(frameMs);
}
function loop() {
resize();
controls.update();
tick();
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
rebuild();
requestAnimationFrame(loop);
window.gp = {
renderer, scene, camera, controls, sim: () => sim,
step: (steps = 240) => { const d = 2.4e-4; for (let i = 0; i < steps; i++) sim.step(d); syncMesh(); },
setShape: (s) => { shapeSel.value = s; rebuild(); },
setMaterial: (m) => { matSel.value = m; sim.setMaterial(m); },
frame: () => { resize(); renderer.render(scene, camera); },
};