特效介绍
使用 Three.js 的 WebGL 小实验。一个极简却令人上瘾的 Web 交互玩具。你可以像撕真实纸张一样,用鼠标拖拽、拉伸、撕碎一层又一层的“纸”,撕碎当前层后下一层会自动出现,形成无限循环。整个项目浓缩在单个 index.html 文件中,无需构建、无外部依赖(Three.js 通过 import map 从 unpkg 加载),却实现了高质量的布料物理、动态撕裂、程序化纹理和沉浸式交互体验。
使用方法
核心玩法与交互亮点
左键拖拽:抓住并拉伸布料,感受真实的弹性与形变。
右键拖拽:直接撕碎纸张,产生自然碎裂效果。
R 键:快速重置当前层。
无限层级:撕碎足够多后自动切换下一层,每层都有独特程序生成的视觉内容。
物理反馈:拉扯时布料会自然下垂、抖动,碎片会带有惯性飞走,不会遮挡下一层。
操作上手极快,却能带来强烈的破坏欲和满足感,适合用来“摸鱼”放松或作为压力释放工具。
实现代码
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Three.js 如何用几百行代码实现"无限撕纸"的交互快感</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #ddd9d0; overflow: hidden; }
canvas { display: block; }
#hint {
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
font: 500 11px/1 Inter, -apple-system, sans-serif;
letter-spacing: 0.12em; text-transform: uppercase;
color: #6a6a6a; user-select: none; pointer-events: none;
}
#counter {
position: fixed; right: 22px; top: 18px;
font: 600 12px/1 Inter, sans-serif;
letter-spacing: 0.1em; text-transform: uppercase;
color: #555; user-select: none; pointer-events: none;
background: rgba(0,0,0,0.04); padding: 8px 14px; border-radius: 999px;
backdrop-filter: blur(4px);
}
#loading {
position: fixed; inset: 0; display: grid; place-items: center;
background: #ddd9d0; color: #555; font: 500 14px Inter, sans-serif;
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div id="loading">loading…</div>
<div id="counter">Layer 1</div>
<div id="hint">Drag to grab · Right-click drag to shred · Press R to reset</div>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.167.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
// ============================================================================
// config
// ============================================================================
const PAGE_W = 16;
const PAGE_H = 10;
const W_DIV = 64;
const H_DIV = 40;
const REST_X = PAGE_W / W_DIV;
const REST_Y = PAGE_H / H_DIV;
const TEAR_RATIO = 4.0; // small stretch tolerance, but still tears with a real pull
const ITER = 2;
const FRICTION = 0.985;
const GRAVITY = -0.0009; // gentle, so it feels like fabric not paper
const PASSIVE_GRAVITY = -0.014; // remnants whoosh off so they don't block the next layer
const MOUSE_PULL_RADIUS = 0.7;
const MOUSE_TEAR_RADIUS = 0.45;
const PULL_STRENGTH = 1.25; // grabby enough to actually rip when you mean it
const FOV = 35;
// auto-detach + auto-advance thresholds (fraction of original cells still alive)
const UNPIN_AT = 0.94; // any meaningful tear (~6%) detaches the top — no more grinding
const ADVANCE_AT = 0.30; // count-based advance still works as a backup
const POST_UNPIN_ADVANCE = 0.35; // …seconds after top detaches before the next layer takes over
const ADVANCE_COOLDOWN = 1.2;
const PASSIVE_LIFE = 1.0;
// ============================================================================
// scene
// ============================================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color('#ddd9d0');
const camera = new THREE.PerspectiveCamera(FOV, innerWidth / innerHeight, 0.1, 200);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
function fitCamera() {
const aspect = innerWidth / innerHeight;
camera.aspect = aspect;
const fovRad = THREE.MathUtils.degToRad(FOV);
const padding = 1.18;
const fitH = (PAGE_H * padding * 0.5) / Math.tan(fovRad / 2);
const fitW = (PAGE_W * padding * 0.5) / (Math.tan(fovRad / 2) * aspect);
camera.position.set(0, 0, Math.max(fitH, fitW));
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
}
fitCamera();
addEventListener('resize', () => { fitCamera(); renderer.setSize(innerWidth, innerHeight); });
// ============================================================================
// canvas drawing helpers
// ============================================================================
function rng(seed) {
let s = seed | 0;
return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
}
function rounded(g, x, y, w, h, r) {
g.beginPath();
g.moveTo(x + r, y);
g.arcTo(x + w, y, x + w, y + h, r);
g.arcTo(x + w, y + h, x, y + h, r);
g.arcTo(x, y + h, x, y, r);
g.arcTo(x, y, x + w, y, r);
g.closePath();
}
function tornRect(g, cx, cy, w, h, jag, seed) {
const r = rng(seed);
const steps = 26;
g.beginPath();
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const px = cx - w / 2 + t * w + (r() - 0.5) * jag * 0.6;
const py = cy - h / 2 + (r() - 0.5) * jag;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
for (let i = 1; i <= steps; i++) {
const t = i / steps;
g.lineTo(cx + w / 2 + (r() - 0.5) * jag, cy - h / 2 + t * h + (r() - 0.5) * jag * 0.6);
}
for (let i = 1; i <= steps; i++) {
const t = i / steps;
g.lineTo(cx + w / 2 - t * w + (r() - 0.5) * jag * 0.6, cy + h / 2 + (r() - 0.5) * jag);
}
for (let i = 1; i < steps; i++) {
const t = i / steps;
g.lineTo(cx - w / 2 + (r() - 0.5) * jag, cy + h / 2 - t * h + (r() - 0.5) * jag * 0.6);
}
g.closePath();
}
function organicBlob(g, cx, cy, r0, seed) {
const r = rng(seed);
const n = 14;
const offs = Array.from({ length: n }, () => 0.78 + r() * 0.32);
g.beginPath();
for (let i = 0; i <= n; i++) {
const a = (i / n) * Math.PI * 2;
const rr = r0 * offs[i % n];
const px = cx + Math.cos(a) * rr;
const py = cy + Math.sin(a) * rr;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.closePath();
}
function scribble(g, x, y, scale, loops, seed) {
const r = rng(seed);
g.beginPath();
const N = 200;
for (let i = 0; i <= N; i++) {
const t = i / N;
const a = t * loops * Math.PI * 2 + Math.sin(t * 7 + seed) * 0.6;
const rr = scale * (1 - t * 0.7) + 6;
const wob = (r() - 0.5) * 4;
const px = x + Math.cos(a) * rr + wob;
const py = y + Math.sin(a) * rr * 0.7 + wob;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.stroke();
}
function gear(g, cx, cy, R, teeth, color) {
g.fillStyle = color;
g.beginPath();
for (let i = 0; i < teeth * 2; i++) {
const a = (i / (teeth * 2)) * Math.PI * 2;
const rr = i % 2 === 0 ? R : R * 0.78;
const px = cx + Math.cos(a) * rr;
const py = cy + Math.sin(a) * rr;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.closePath();
g.fill();
g.globalCompositeOperation = 'destination-out';
g.beginPath();
g.arc(cx, cy, R * 0.34, 0, Math.PI * 2);
g.fill();
g.globalCompositeOperation = 'source-over';
}
function cross(g, x, y, s) {
g.beginPath();
g.moveTo(x - s, y); g.lineTo(x + s, y);
g.moveTo(x, y - s); g.lineTo(x, y + s);
g.moveTo(x - s * 0.7, y - s * 0.7); g.lineTo(x + s * 0.7, y + s * 0.7);
g.moveTo(x - s * 0.7, y + s * 0.7); g.lineTo(x + s * 0.7, y - s * 0.7);
g.stroke();
}
// ============================================================================
// layer content
// ============================================================================
const PALETTES = [
{ bg: '#e8e3d6', shapes: ['#e88a73', '#bfb1d6', '#9bb9aa', '#f0d9c8', '#a799c9'], ink: '#16161a', text: '#0a0a0a' },
{ bg: '#f5f3ec', shapes: ['#5b8aa6', '#e7a063', '#a3b18a', '#c8c0af', '#7c6ba8'], ink: '#0a0a0a', text: '#0a0a0a' },
{ bg: '#ecf0e9', shapes: ['#d4756c', '#7e9eb5', '#c9b785', '#9aa68c', '#a08bc4'], ink: '#16161a', text: '#0a0a0a' },
{ bg: '#1a1a1a', shapes: ['#ff6b6b', '#5dd6e0', '#fbe25b', '#9ee492', '#c690ff'], ink: '#fff', text: '#f5f3ec' },
{ bg: '#fdf3e7', shapes: ['#ff8a72', '#9bb9aa', '#a799c9', '#f0d9c8', '#5b8aa6'], ink: '#0a0a0a', text: '#0a0a0a' },
{ bg: '#e9e9eb', shapes: ['#f4a261', '#264653', '#2a9d8f', '#e76f51', '#e9c46a'], ink: '#16161a', text: '#0a0a0a' },
{ bg: '#1f2125', shapes: ['#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#a8dadc'], ink: '#f1f1f1', text: '#f1f1f1' },
{ bg: '#fce8e0', shapes: ['#9381ff', '#ff70a6', '#ffd670', '#70d6ff', '#3a3a3a'], ink: '#16161a', text: '#0a0a0a' },
{ bg: '#e0f0ee', shapes: ['#ef476f', '#ffd166', '#06d6a0', '#118ab2', '#073b4c'], ink: '#073b4c', text: '#073b4c' },
{ bg: '#0d1b2a', shapes: ['#e63946', '#f1faee', '#a8dadc', '#457b9d', '#fcbf49'], ink: '#f1f1f1', text: '#f1faee' },
];
const HEADINGS = [
['GO ON', 'TEAR IT UP'],
['THERE IS', 'MORE BELOW'],
['STILL', 'GOING'],
['HOW DEEP', 'CAN YOU GO'],
['YEP', 'KEEP GOING'],
['NICE', 'SHREDDING'],
['OK NOW', 'YOU SHOW OFF'],
['ONLY', 'PAPER'],
['HMM', 'INTERESTING'],
['WELL', 'WELL WELL'],
['STILL', 'NO BOTTOM'],
['ARE YOU', 'TIRED YET'],
['IT NEVER', 'ENDS'],
['LIKE THE', 'OCEAN'],
['LAYERS', 'ALL THE WAY'],
['DON\'T STOP', 'NOW'],
['SHRED', 'MORE'],
['BREAK', 'STUFF'],
['INFINITE', 'SCROLL'],
['JUST', 'PAPER'],
['NO', 'BOTTOM'],
['ANOTHER', 'ROUND'],
['ONE MORE', 'ONE MORE'],
['STILL HERE', 'HUH?'],
['YOU\'RE', 'PERSISTENT'],
['VERY', 'IMPRESSIVE'],
['TURTLES', 'ALL DOWN'],
['DEEPER', 'STILL'],
['WAIT', 'WHAT'],
['UH OH', 'KEEP GOING'],
];
const SUBS = [
'Grab it. Rip it. Shred it.',
'Keep going.',
'How deep can you go?',
'There is always more.',
'Don\'t stop now.',
'You are doing great.',
'Beautiful tearing.',
'Pure chaos.',
'See you on the next one.',
'Layers all the way down.',
];
function makeLayerTexture(idx) {
const c = document.createElement('canvas');
c.width = 2048; c.height = 1280;
const g = c.getContext('2d');
const palette = PALETTES[(idx - 1) % PALETTES.length];
const heading = HEADINGS[(idx - 1) % HEADINGS.length];
const sub = SUBS[(idx - 1) % SUBS.length];
const r = rng(idx * 17 + 3);
g.fillStyle = palette.bg;
g.fillRect(0, 0, c.width, c.height);
// soft vignette
const vg = g.createRadialGradient(c.width / 2, c.height / 2, 200, c.width / 2, c.height / 2, c.width * 0.7);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.07)');
g.fillStyle = vg; g.fillRect(0, 0, c.width, c.height);
// paper rectangles
const rectN = 4 + Math.floor(r() * 3);
for (let i = 0; i < rectN; i++) {
const cx = 240 + r() * (c.width - 480);
const cy = 220 + r() * (c.height - 440);
const w = 280 + r() * 320;
const h = 200 + r() * 280;
const rot = (r() - 0.5) * 0.3;
const color = palette.shapes[Math.floor(r() * palette.shapes.length)];
g.save(); g.translate(cx, cy); g.rotate(rot);
g.fillStyle = color;
tornRect(g, 0, 0, w, h, 8, idx * 31 + i * 11);
g.fill(); g.restore();
}
// organic blobs
const blobN = 4 + Math.floor(r() * 3);
for (let i = 0; i < blobN; i++) {
const cx = r() * c.width;
const cy = r() * c.height;
const rr = 30 + r() * 60;
g.fillStyle = palette.shapes[Math.floor(r() * palette.shapes.length)];
organicBlob(g, cx, cy, rr, idx * 41 + i * 19);
g.fill();
}
// gear (occasional)
if (idx % 3 !== 0) {
gear(g, 200 + r() * (c.width - 400), 200 + r() * (c.height - 400),
110 + r() * 50, 8 + Math.floor(r() * 4),
palette.shapes[Math.floor(r() * palette.shapes.length)]);
}
// scribbles + crosses
g.strokeStyle = palette.ink; g.lineCap = 'round'; g.lineJoin = 'round';
g.lineWidth = 4;
const sN = 2 + Math.floor(r() * 2);
for (let i = 0; i < sN; i++) {
scribble(g, 200 + r() * (c.width - 400), 150 + r() * (c.height - 300),
70 + r() * 60, 4 + Math.floor(r() * 5), idx * 53 + i * 29);
}
const xN = 2 + Math.floor(r() * 3);
for (let i = 0; i < xN; i++) {
cross(g, 200 + r() * (c.width - 400), 150 + r() * (c.height - 300), 16 + r() * 10);
}
// ---- center text ----
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = palette.text;
g.font = '900 200px "Special Gothic Expanded One", "Arial Black", Impact, sans-serif';
const isDark = palette.bg.startsWith('#1') || palette.bg.startsWith('#0');
g.shadowColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.18)';
g.shadowOffsetX = 6; g.shadowOffsetY = 6;
g.fillText(heading[0], c.width / 2, 510);
g.fillText(heading[1], c.width / 2, 720);
g.shadowColor = 'transparent';
g.fillStyle = palette.text;
g.globalAlpha = 0.85;
g.font = '500 36px Inter, sans-serif';
g.fillText(sub, c.width / 2, 870);
g.globalAlpha = 1;
// pill button
const bw = 480, bh = 92, bx = c.width / 2 - bw / 2, by = 940;
g.fillStyle = palette.text;
rounded(g, bx, by, bw, bh, bh / 2);
g.fill();
g.fillStyle = palette.bg;
g.font = '500 30px Inter, sans-serif';
g.fillText(`Layer ${idx} · click me`, c.width / 2, by + bh / 2 + 2);
// tiny logo bug
g.fillStyle = palette.text;
g.beginPath(); g.arc(70, 70, 26, 0, Math.PI * 2); g.fill();
g.fillStyle = palette.bg;
g.font = '700 22px Inter, sans-serif';
g.fillText(idx <= 99 ? String(idx) : '∞', 70, 78);
// little settings card on layer 2 — keeps the original "reveal a real UI" moment
if (idx === 2) {
const px = c.width - 460 - 60, py = 220, pw = 460, ph = 480;
g.fillStyle = 'rgba(0,0,0,0.10)';
rounded(g, px + 6, py + 12, pw, ph, 18); g.fill();
g.fillStyle = '#fff';
rounded(g, px, py, pw, ph, 18); g.fill();
g.strokeStyle = '#e3e0d6'; g.lineWidth = 2;
rounded(g, px, py, pw, ph, 18); g.stroke();
g.fillStyle = '#0a0a0a'; g.textAlign = 'left';
g.font = '700 32px Inter, sans-serif';
g.fillText('Settings', px + 28, py + 48);
let cy = py + 90;
for (const [label, value] of [['Name', 'Somya'], ['Email', 'somya@example.com']]) {
g.fillStyle = '#666'; g.font = '500 18px Inter, sans-serif';
g.fillText(label, px + 28, cy);
g.fillStyle = '#f5f4ee';
rounded(g, px + 28, cy + 12, pw - 56, 44, 10); g.fill();
g.strokeStyle = '#e3e0d6'; g.lineWidth = 1.5;
rounded(g, px + 28, cy + 12, pw - 56, 44, 10); g.stroke();
g.fillStyle = '#0a0a0a'; g.font = '500 20px Inter, sans-serif';
g.fillText(value, px + 44, cy + 40);
cy += 80;
}
g.fillStyle = '#666'; g.font = '500 18px Inter, sans-serif';
g.fillText('Volume', px + 28, cy);
cy += 24;
g.fillStyle = '#e3e0d6'; rounded(g, px + 28, cy, pw - 56, 6, 3); g.fill();
g.fillStyle = '#3b82f6'; rounded(g, px + 28, cy, (pw - 56) * 0.7, 6, 3); g.fill();
g.beginPath(); g.arc(px + 28 + (pw - 56) * 0.7, cy + 3, 11, 0, Math.PI * 2); g.fill();
cy += 30;
for (const [label, on] of [['Auto-save', true], ['Confetti', false]]) {
g.fillStyle = '#0a0a0a'; g.font = '500 18px Inter, sans-serif';
g.fillText(label, px + 28, cy + 16);
const tx = px + pw - 28 - 44, ty = cy + 4;
g.fillStyle = on ? '#3b82f6' : '#cfcdc4';
rounded(g, tx, ty, 44, 24, 12); g.fill();
g.fillStyle = '#fff';
g.beginPath(); g.arc(on ? tx + 32 : tx + 12, ty + 12, 8, 0, Math.PI * 2); g.fill();
cy += 38;
}
}
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
return tex;
}
// ============================================================================
// physics state — shared connectivity, swappable particle state
// ============================================================================
const W = W_DIV + 1;
const H = H_DIV + 1;
const N = W * H;
const positions = new Float32Array(N * 3);
const prev = new Float32Array(N * 3);
const pinned = new Uint8Array(N);
const isolation = new Float32Array(N);
const uvs = new Float32Array(N * 2);
function initParticles() {
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = y * W + x;
const px = (x / W_DIV - 0.5) * PAGE_W;
const py = (0.5 - y / H_DIV) * PAGE_H;
positions[i * 3] = px; positions[i * 3 + 1] = py; positions[i * 3 + 2] = 0;
prev[i * 3] = px; prev[i * 3 + 1] = py; prev[i * 3 + 2] = 0;
uvs[i * 2] = x / W_DIV;
uvs[i * 2 + 1] = 1 - y / H_DIV;
pinned[i] = (y === 0) ? 1 : 0;
isolation[i] = 0;
}
}
}
initParticles();
// constraints (connectivity is constant)
const H_COUNT = (W - 1) * H;
const V_COUNT = W * (H - 1);
const C_COUNT = H_COUNT + V_COUNT;
const cA = new Int32Array(C_COUNT);
const cB = new Int32Array(C_COUNT);
const cRest = new Float32Array(C_COUNT);
const cAlive = new Uint8Array(C_COUNT);
(function buildConstraints() {
let k = 0;
for (let y = 0; y < H; y++) for (let x = 0; x < W - 1; x++) {
cA[k] = y * W + x; cB[k] = y * W + x + 1; cRest[k] = REST_X; cAlive[k] = 1; k++;
}
for (let y = 0; y < H - 1; y++) for (let x = 0; x < W; x++) {
cA[k] = y * W + x; cB[k] = (y + 1) * W + x; cRest[k] = REST_Y; cAlive[k] = 1; k++;
}
})();
const cellsW = W_DIV, cellsH = H_DIV;
const TOTAL_CELLS = cellsW * cellsH;
const cellAlive = new Uint8Array(TOTAL_CELLS);
cellAlive.fill(1);
function killCellsForConstraint(arr, k) {
if (k < H_COUNT) {
const x = k % (W - 1);
const y = Math.floor(k / (W - 1));
if (y > 0) arr[(y - 1) * cellsW + x] = 0;
if (y < cellsH) arr[y * cellsW + x] = 0;
} else {
const idx = k - H_COUNT;
const x = idx % W;
const y = Math.floor(idx / W);
if (x > 0) arr[y * cellsW + (x - 1)] = 0;
if (x < cellsW) arr[y * cellsW + x] = 0;
}
}
// ============================================================================
// active mesh
// ============================================================================
const indicesArr = new Uint32Array(TOTAL_CELLS * 6);
function buildIndexFrom(cellAliveArr) {
let k = 0;
for (let cy = 0; cy < cellsH; cy++) {
for (let cx = 0; cx < cellsW; cx++) {
if (!cellAliveArr[cy * cellsW + cx]) continue;
const tl = cy * W + cx;
const tr = cy * W + cx + 1;
const bl = (cy + 1) * W + cx;
const br = (cy + 1) * W + cx + 1;
indicesArr[k++] = tl; indicesArr[k++] = bl; indicesArr[k++] = tr;
indicesArr[k++] = tr; indicesArr[k++] = bl; indicesArr[k++] = br;
}
}
return indicesArr.slice(0, k);
}
function rebuildIndex() {
geometry.setIndex(new THREE.BufferAttribute(buildIndexFrom(cellAlive), 1));
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
let frontTexture, backTexture, mesh, backMesh, backMat;
let layerIndex = 1;
const counterEl = document.getElementById('counter');
function updateCounter() {
counterEl.textContent = `Layer ${layerIndex}`;
}
function buildScene() {
frontTexture = makeLayerTexture(1);
backTexture = makeLayerTexture(2);
const frontMat = new THREE.MeshBasicMaterial({
map: frontTexture, side: THREE.DoubleSide,
});
mesh = new THREE.Mesh(geometry, frontMat);
scene.add(mesh);
rebuildIndex();
const backGeo = new THREE.PlaneGeometry(PAGE_W * 1.05, PAGE_H * 1.05);
backMat = new THREE.MeshBasicMaterial({ map: backTexture });
backMesh = new THREE.Mesh(backGeo, backMat);
backMesh.position.z = -0.4;
scene.add(backMesh);
}
// ============================================================================
// passive (falling) cloth instances
// ============================================================================
const passives = [];
function snapshotPassive(texture, dropImpulse = 0) {
// copy the active state into an independent passive instance
const posCopy = positions.slice();
const prevCopy = prev.slice();
if (dropImpulse > 0) {
// verlet velocity = pos - prev. raise prev.y above pos.y so points move down.
for (let i = 0; i < N; i++) {
prevCopy[i * 3 + 1] = posCopy[i * 3 + 1] + dropImpulse;
}
}
const cAliveCopy = cAlive.slice();
const cellAliveCopy = cellAlive.slice();
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(posCopy, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs.slice(), 2));
geo.setIndex(new THREE.BufferAttribute(buildIndexFrom(cellAliveCopy), 1));
const mat = new THREE.MeshBasicMaterial({
map: texture, side: THREE.DoubleSide,
transparent: true, opacity: 1,
});
const m = new THREE.Mesh(geo, mat);
scene.add(m);
return {
positions: posCopy, prev: prevCopy,
cAlive: cAliveCopy,
geometry: geo, material: mat, mesh: m, texture,
age: 0,
};
}
function stepPassive(p, dt) {
p.age += dt;
const pos = p.positions, prv = p.prev, alive = p.cAlive;
// verlet (no pin, no mouse, stronger gravity)
for (let i = 0; i < N; i++) {
const i3 = i * 3;
const x = pos[i3], y = pos[i3 + 1], z = pos[i3 + 2];
const px = prv[i3], py = prv[i3 + 1], pz = prv[i3 + 2];
prv[i3] = x; prv[i3 + 1] = y; prv[i3 + 2] = z;
pos[i3] = x + (x - px) * 0.99;
pos[i3 + 1] = y + (y - py) * 0.99 + PASSIVE_GRAVITY;
pos[i3 + 2] = z + (z - pz) * 0.99 + 0.006;
}
// constraint relax (preserves rigidity of remaining chunks)
for (let k = 0; k < C_COUNT; k++) {
if (!alive[k]) continue;
const a = cA[k], b = cB[k];
const a3 = a * 3, b3 = b * 3;
const dx = pos[a3] - pos[b3];
const dy = pos[a3 + 1] - pos[b3 + 1];
const dz = pos[a3 + 2] - pos[b3 + 2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist === 0) continue;
if (dist > cRest[k] * 6) { alive[k] = 0; continue; }
const diff = (cRest[k] - dist) / dist * 0.5;
pos[a3] += dx * diff;
pos[a3 + 1] += dy * diff;
pos[a3 + 2] += dz * diff;
pos[b3] -= dx * diff;
pos[b3 + 1] -= dy * diff;
pos[b3 + 2] -= dz * diff;
}
p.geometry.attributes.position.needsUpdate = true;
if (p.age > PASSIVE_LIFE - 1.5) {
p.material.opacity = Math.max(0, 1 - (p.age - (PASSIVE_LIFE - 1.5)) / 1.5);
}
}
function disposePassive(p) {
scene.remove(p.mesh);
p.geometry.dispose();
p.material.dispose();
if (p.texture && p.texture.dispose) p.texture.dispose();
}
// ============================================================================
// input
// ============================================================================
const mouse = {
x: 0, y: 0, px: 0, py: 0,
down: false, button: 0, active: false,
};
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const planeZ = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const hit = new THREE.Vector3();
function syncMouse(clientX, clientY) {
const rect = renderer.domElement.getBoundingClientRect();
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, camera);
raycaster.ray.intersectPlane(planeZ, hit);
mouse.px = mouse.x; mouse.py = mouse.y;
mouse.x = hit.x; mouse.y = hit.y;
mouse.active = true;
}
renderer.domElement.addEventListener('mousedown', e => {
mouse.down = true; mouse.button = e.button;
syncMouse(e.clientX, e.clientY);
mouse.px = mouse.x; mouse.py = mouse.y;
e.preventDefault();
});
addEventListener('mouseup', () => { mouse.down = false; });
renderer.domElement.addEventListener('mousemove', e => syncMouse(e.clientX, e.clientY));
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
renderer.domElement.addEventListener('touchstart', e => {
const t = e.touches[0];
mouse.down = true; mouse.button = 0;
syncMouse(t.clientX, t.clientY);
mouse.px = mouse.x; mouse.py = mouse.y;
e.preventDefault();
}, { passive: false });
renderer.domElement.addEventListener('touchmove', e => {
const t = e.touches[0]; syncMouse(t.clientX, t.clientY);
e.preventDefault();
}, { passive: false });
renderer.domElement.addEventListener('touchend', () => { mouse.down = false; });
addEventListener('keydown', e => { if (e.key === 'r' || e.key === 'R') hardReset(); });
function hardReset() {
// remove passives
for (const p of passives) disposePassive(p);
passives.length = 0;
// dispose old textures
if (frontTexture) frontTexture.dispose();
if (backTexture) backTexture.dispose();
// reset state
layerIndex = 1;
initParticles();
cAlive.fill(1);
cellAlive.fill(1);
topUnpinned = false;
topUnpinAt = 0;
advanceCooldown = 0;
// new textures
frontTexture = makeLayerTexture(1);
backTexture = makeLayerTexture(2);
mesh.material.map = frontTexture; mesh.material.needsUpdate = true;
backMat.map = backTexture; backMat.needsUpdate = true;
rebuildIndex();
geometry.attributes.position.needsUpdate = true;
updateCounter();
}
// ============================================================================
// physics step
// ============================================================================
const MR2 = MOUSE_PULL_RADIUS * MOUSE_PULL_RADIUS;
const TR2 = MOUSE_TEAR_RADIUS * MOUSE_TEAR_RADIUS;
let dirtyIndex = false;
let topUnpinned = false;
let topUnpinAt = 0; // seconds-since-load when top got unpinned
let advanceCooldown = 0;
let advancing = false;
let elapsed = 0;
let lastTearX = 0, lastTearY = 0, lastTearValid = false;
function pointSegDist2(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-9) {
const a = px - ax, b = py - ay;
return a * a + b * b;
}
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
if (t < 0) t = 0; else if (t > 1) t = 1;
const cx = ax + t * dx, cy = ay + t * dy;
const a = px - cx, b = py - cy;
return a * a + b * b;
}
function killConstraint(k) {
cAlive[k] = 0;
isolation[cA[k]] = Math.min(6, isolation[cA[k]] + 1);
isolation[cB[k]] = Math.min(6, isolation[cB[k]] + 1);
killCellsForConstraint(cellAlive, k);
dirtyIndex = true;
__frameTearCount++;
}
function stepActive() {
// verlet integrate
for (let i = 0; i < N; i++) {
if (pinned[i]) continue;
const i3 = i * 3;
let x = positions[i3], y = positions[i3 + 1], z = positions[i3 + 2];
const ppx = prev[i3], ppy = prev[i3 + 1], ppz = prev[i3 + 2];
if (mouse.down && mouse.active && mouse.button === 0) {
const dx = x - mouse.x, dy = y - mouse.y;
const d2 = dx * dx + dy * dy;
if (d2 < MR2) {
const fall = 1 - Math.sqrt(d2) / MOUSE_PULL_RADIUS;
x += (mouse.x - mouse.px) * PULL_STRENGTH * fall;
y += (mouse.y - mouse.py) * PULL_STRENGTH * fall;
}
}
const nx = x + (x - ppx) * FRICTION;
const ny = y + (y - ppy) * FRICTION + GRAVITY * (1 + isolation[i] * 0.4);
const nz = z + (z - ppz) * FRICTION + isolation[i] * 0.0011;
prev[i3] = x; prev[i3 + 1] = y; prev[i3 + 2] = z;
positions[i3] = nx; positions[i3 + 1] = ny; positions[i3 + 2] = nz;
}
// tear via right-click — sweep the segment from last frame's tear point to now
if (mouse.down && mouse.active && mouse.button === 2) {
const mx = mouse.x, my = mouse.y;
const ax = lastTearValid ? lastTearX : mx;
const ay = lastTearValid ? lastTearY : my;
for (let k = 0; k < C_COUNT; k++) {
if (!cAlive[k]) continue;
const a = cA[k], b = cB[k];
const cx = (positions[a * 3] + positions[b * 3]) * 0.5;
const cy = (positions[a * 3 + 1] + positions[b * 3 + 1]) * 0.5;
if (pointSegDist2(cx, cy, ax, ay, mx, my) < TR2) killConstraint(k);
}
lastTearX = mx; lastTearY = my; lastTearValid = true;
} else {
lastTearValid = false;
}
// constraint relax + auto-tear when stretched too far
for (let it = 0; it < ITER; it++) {
for (let k = 0; k < C_COUNT; k++) {
if (!cAlive[k]) continue;
const a = cA[k], b = cB[k];
const a3 = a * 3, b3 = b * 3;
const dx = positions[a3] - positions[b3];
const dy = positions[a3 + 1] - positions[b3 + 1];
const dz = positions[a3 + 2] - positions[b3 + 2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist === 0) continue;
if (dist > cRest[k] * TEAR_RATIO) { killConstraint(k); continue; }
const diff = (cRest[k] - dist) / dist * 0.5;
const ox = dx * diff, oy = dy * diff, oz = dz * diff;
const m1 = pinned[a] ? 0 : 1;
const m2 = pinned[b] ? 0 : 1;
const sum = m1 + m2;
if (sum === 0) continue;
const w1 = (m1 / sum) * 2, w2 = (m2 / sum) * 2;
positions[a3] += ox * w1;
positions[a3 + 1] += oy * w1;
positions[a3 + 2] += oz * w1;
positions[b3] -= ox * w2;
positions[b3 + 1] -= oy * w2;
positions[b3 + 2] -= oz * w2;
}
}
if (dirtyIndex) { rebuildIndex(); dirtyIndex = false; }
geometry.attributes.position.needsUpdate = true;
}
// ============================================================================
// progress / advance
// ============================================================================
function aliveFraction() {
let alive = 0;
for (let i = 0; i < TOTAL_CELLS; i++) if (cellAlive[i]) alive++;
return alive / TOTAL_CELLS;
}
let __frameTearCount = 0;
window.__state = () => ({
layer: layerIndex,
alive: aliveFraction(),
topUnpinned, topUnpinAt, elapsed,
passives: passives.length,
mouseDown: mouse.down, mouseBtn: mouse.button, mouseActive: mouse.active,
mouseX: mouse.x, mouseY: mouse.y,
totalTears: __frameTearCount,
});
window.__forceShred = (frac = 0.9) => {
// sanity check the advance pipeline
const target = Math.floor(C_COUNT * frac);
for (let k = 0; k < target; k++) killConstraint(k);
};
function progress(dt) {
elapsed += dt;
advanceCooldown = Math.max(0, advanceCooldown - dt);
if (advancing) return;
const pct = aliveFraction();
if (!topUnpinned && pct < UNPIN_AT) {
for (let x = 0; x < W; x++) pinned[x] = 0;
// sever every constraint touching the top row so the hanging chunks plummet,
// not swing — this is what you've been seeing get stuck.
for (let k = 0; k < C_COUNT; k++) {
if (!cAlive[k]) continue;
const ay = Math.floor(cA[k] / W);
const by = Math.floor(cB[k] / W);
if (ay === 0 || by === 0) killConstraint(k);
}
topUnpinned = true;
topUnpinAt = elapsed;
}
// advance when (a) most of it is torn, OR (b) the strip has been falling for a beat —
// either way, force-unpin the top so the remainder falls cleanly into the passive
const triggerByCount = pct < ADVANCE_AT;
const triggerByTime = topUnpinned && (elapsed - topUnpinAt) > POST_UNPIN_ADVANCE;
if ((triggerByCount || triggerByTime) && advanceCooldown <= 0) {
for (let x = 0; x < W; x++) pinned[x] = 0;
advance();
}
}
function advance() {
advancing = true;
// 1) capture current torn front as a passive falling instance (uses old front texture)
const oldFront = frontTexture;
// shatter any still-connected shreds and give them a hard downward kick so
// the previous layer never lingers on top of the new one
cAlive.fill(0);
passives.push(snapshotPassive(oldFront, 0.4));
// 2) promote current back to be the new front
frontTexture = backTexture;
// 3) generate the next layer behind
layerIndex++;
backTexture = makeLayerTexture(layerIndex + 1);
// 4) reset active particles + connectivity
initParticles();
cAlive.fill(1);
cellAlive.fill(1);
topUnpinned = false;
topUnpinAt = 0;
isolation.fill(0);
rebuildIndex();
geometry.attributes.position.needsUpdate = true;
// 5) swap textures on materials
mesh.material.map = frontTexture; mesh.material.needsUpdate = true;
backMat.map = backTexture; backMat.needsUpdate = true;
updateCounter();
advanceCooldown = ADVANCE_COOLDOWN;
advancing = false;
}
// ============================================================================
// boot
// ============================================================================
const ready = (document.fonts && document.fonts.ready) ? document.fonts.ready : Promise.resolve();
ready.then(() => {
buildScene();
updateCounter();
document.getElementById('loading').remove();
let last = performance.now();
function tick(now) {
let dt = (now - last) / 1000;
last = now;
if (dt > 0.05) dt = 0.05;
stepActive();
progress(dt);
for (let i = passives.length - 1; i >= 0; i--) {
stepPassive(passives[i], dt);
if (passives[i].age > PASSIVE_LIFE) {
disposePassive(passives[i]);
passives.splice(i, 1);
}
}
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
</script>
</body>
</html>