React 物理引擎的“硬核”浪漫:用 Verlet 积分构建声明式布料系统
各位同学,大家好!
今天我们要聊的东西,有点“硬核”,有点“疯狂”,甚至有点“反直觉”。
通常我们认为 React 是做什么的?它是处理 UI 的,它是声明式的,它是“描述状态,React 会自动帮你搞出 UI”。它是如此优雅,如此干净,它讨厌副作用,它讨厌不可预测的数值变化。
但是,物理引擎呢?物理引擎是什么?物理引擎是混乱的,它是基于时间的,它是每一帧都在疯狂改变数值的“副作用之王”。当你把一块布料扔到屏幕上,它的每一根纤维都在疯狂地拉扯、碰撞、形变。这简直就是 React 精神的死敌,对吧?
但是,今天我要教大家如何反其道而行之。我们要用 React 那些看似柔弱的钩子,去驯服最狂野的 Verlet 积分算法。我们要构建一个声明式的物理世界。
准备好了吗?让我们开始这场代码的冒险。
第一部分:当 React 遇到物理——一场注定失败的罗曼史?
想象一下,你有一个女朋友,她叫 React。她非常挑剔,非常完美主义。她喜欢一切是“静态”的,喜欢一切是“可预测”的。你对她画一个圆,她就画一个圆,你改变状态,她重新渲染。
现在,想象一下,你给她看了一个物理引擎。这个物理引擎是个疯狂的摇滚乐手。每一毫秒,它都在大喊大叫:“X 坐标变了!Y 坐标变了!速度变了!加速度变了!重力拉着我!”
React 会崩溃的。她绝对会崩溃。如果你在 render 函数里写 x += 1,React 会把你赶出这个类组件,甚至把你从 React 社区拉黑。
但是! 我们今天不讲这种“反模式”。我们要做的是“架构大师”。我们要利用 React 的状态管理能力,来驱动底层的物理计算。
这就像是你雇佣了一位疯狂的摇滚乐手(物理引擎),但你要用 React 这种优雅的指挥家(状态管理)来控制他。你不能让他直接对着观众(浏览器)大喊大叫,你要让他通过你的乐谱(状态更新)来演奏。
核心思想是:将渲染循环与物理循环分离。
React 负责渲染画面,但物理引擎有自己的心跳。这个心跳由 requestAnimationFrame 驱动,它不等待 React 的渲染周期。这就是我们构建复杂系统的基石。
第二部分:Verlet 积分——牛顿的“懒人”算法
在开始写代码之前,我们需要一个强大的武器。什么武器?不是 React,而是 Verlet 积分。
如果你学过物理,你知道欧拉积分。欧拉积分就像是一个只会死记硬背的学生。他看了一眼当前位置,看了一眼速度,然后告诉你:“下一帧我就在这里。”
但 Verlet 积分不同,它是个“懒人”,但它是个聪明的懒人。它不关心速度,它只关心当前位置和上一帧的位置。
公式是这样的:
$$x{new} = 2x – x{old} + a cdot Delta t^2$$
翻译成人话就是:
- 回忆一下你上一帧在哪里(
x_old)。 - 站在你现在的位置(
x)。 - 顺便把刚才走过的路(
x - x_old)加回来。 - 最后加上这一帧受到的力(加速度
a)。
为什么要用它?
因为它是稳定的!在处理布料、绳索这类约束系统时,欧拉积分很容易“爆炸”(数值发散),导致布料突然飞出屏幕。而 Verlet 积分非常稳,它就像橡皮筋一样,怎么扯都不会断。
代码示例:Verlet 积分的本质
让我们先看一个最简单的 Verlet 点的实现。这不需要 React,这是物理的基石。
class Point {
constructor(public x: number, public y: number) {
this.oldX = x;
this.oldY = y;
}
// 上一帧的位置,这是 Verlet 的核心记忆
oldX: number;
oldY: number;
// 更新位置
update(gravity: number, friction: number) {
const vx = (this.x - this.oldX) * friction;
const vy = (this.y - this.oldY) * friction;
// 保存当前位置为“上一帧位置”
this.oldX = this.x;
this.oldY = this.y;
// 应用 Verlet 公式:当前位置 = 当前位置 + 速度 + 重力
this.x += vx;
this.y += vy + gravity;
}
// 简单的约束:限制在某个范围内(比如屏幕边缘)
constrain(width: number, height: number) {
if (this.x > width) {
this.x = width;
this.oldX = this.x + (this.x - this.oldX) * 0.5; // 修正速度,防止无限反弹
} else if (this.x < 0) {
this.x = 0;
this.oldX = this.x + (this.x - this.oldX) * 0.5;
}
// Y 轴同理...
}
}
看到了吗?oldX 和 oldY 是魔法所在。React 的状态也是类似的,它记录了“上一次的状态”,以此来计算“下一次的变化”。只不过 React 是基于 DOM 的变化,而这里是基于物理坐标的变化。
第三部分:布料的灵魂——质点与约束
有了点,我们还需要线。布料就是由无数个点组成的网格,点之间通过弹簧或者更简单的距离约束连接。
如果你用真实的弹簧(胡克定律 $F = -kx$),你会遇到一个问题:弹簧有劲度系数 $k$,如果 $k$ 太大,布料会硬得像铁丝网;如果 $k$ 太小,布料会软得像面条,根本站不住。
Verlet 的解决方案:直接锁死距离。
这就是佩尔斯约束。假设点 A 和点 B 理论上应该相距 10 像素。如果计算出来它们相距 11 像素,那就把点 A 往点 B 靠近 0.5 像素,把点 B 往点 A 靠近 0.5 像素。一帧不够,就迭代 5 次、10 次,直到距离正好是 10 像素。
这就是为什么 Verlet 布料看起来那么自然——它不需要计算弹力,它只需要“纠正”错误。
代码示例:约束求解器
class Stick {
constructor(public p1: Point, public p2: Point, public length: number) {}
// 约束求解核心
resolve() {
const dx = this.p2.x - this.p1.x;
const dy = this.p2.y - this.p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// 如果距离正好,那就别动了,省电
if (dist === 0) return;
// 计算需要移动的距离
const diff = this.length - dist;
const percent = diff / dist / 2; // 平均分配给两个点
const offsetX = dx * percent;
const offsetY = dy * percent;
// 应用位移
this.p1.x -= offsetX;
this.p1.y -= offsetY;
this.p2.x += offsetX;
this.p2.y += offsetY;
}
}
现在,我们有了点,有了线。接下来,我们要把这些塞进 React 里。
第四部分:架构之战——如何避免“React 崩溃”
这是最难的一步。我们要怎么用 React 来管理这些动态变化的物理对象?
错误示范:
function ClothComponent() {
const [points, setPoints] = useState([]);
// 千万不要这样做!
// 这会导致 React 不断重新创建数组对象,触发无限循环
// 或者因为频繁更新状态导致物理计算跟不上,画面卡顿
function tick() {
const newPoints = points.map(p => {
p.update();
return p;
});
setPoints(newPoints);
}
// ... 继续操作
}
React 的状态更新是批处理的,而且是同步的。而物理引擎是异步的,每秒 60 次,每帧都要变。
如果你在 render 里直接改状态,React 会觉得你在试图篡改它的工作。它会先计算新的虚拟 DOM,然后应用更新,然后再渲染。这中间的时间差,对于物理引擎来说,就像是一个世纪那么长。
正确示范:使用 Ref 和 Effect。
我们要把“物理世界”藏在一个 useRef 里。Ref 里的东西,React 看不见,React 不会因为 Ref 里的东西变了就重新渲染。
但是,我们需要 React 来初始化这个世界,并在渲染循环里调用物理计算。
import React, { useState, useEffect, useRef } from 'react';
function PhysicsWorld() {
// 1. 物理世界的状态(用 Ref 包裹,避免触发渲染)
const worldRef = useRef({
points: [],
sticks: [],
width: window.innerWidth,
height: window.innerHeight
});
// 2. 初始化物理世界
useEffect(() => {
const width = window.innerWidth;
const height = window.innerHeight;
const points = [];
const sticks = [];
const spacing = 20;
const cols = Math.floor(width / spacing);
const rows = Math.floor(height / spacing);
// 生成点
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
// 让第一排的点固定住,作为挂点
const pinned = y === 0 && (x % 3 === 0);
points.push({
x: x * spacing,
y: y * spacing,
oldX: x * spacing, // 初始速度为0
oldY: y * spacing,
pinned: pinned
});
}
}
// 生成连接线(约束)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const index = y * cols + x;
if (x < cols - 1) {
sticks.push({ p1: points[index], p2: points[index + 1], length: spacing });
}
if (y < rows - 1) {
sticks.push({ p1: points[index], p2: points[index + cols], length: spacing });
}
}
}
worldRef.current.points = points;
worldRef.current.sticks = sticks;
// 3. 启动物理引擎循环
const animate = () => {
updatePhysics();
render();
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, []); // 空依赖数组,只在挂载时运行一次
// 4. 物理更新逻辑
function updatePhysics() {
const { points, sticks, width, height } = worldRef.current;
const gravity = 0.5;
const friction = 0.99;
const iterations = 5; // 迭代次数越多,布料越硬
// 更新所有点
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned) continue; // 固定点不移动
const vx = (p.x - p.oldX) * friction;
const vy = (p.y - p.oldY) * friction;
p.oldX = p.x;
p.oldY = p.y;
p.x += vx;
p.y += vy + gravity;
// 边界约束
if (p.x > width) { p.x = width; p.oldX = p.x + (p.x - p.oldX) * 0.5; }
else if (p.x < 0) { p.x = 0; p.oldX = p.x + (p.x - p.oldX) * 0.5; }
if (p.y > height) { p.y = height; p.oldY = p.y + (p.y - p.oldY) * 0.5; }
else if (p.y < 0) { p.y = 0; p.oldY = p.y + (p.y - p.oldY) * 0.5; }
}
// 求解约束(多次迭代以增加稳定性)
for (let i = 0; i < iterations; i++) {
for (let j = 0; j < sticks.length; j++) {
const s = sticks[j];
const dx = s.p2.x - s.p1.x;
const dy = s.p2.y - s.p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist === 0) continue;
const diff = s.length - dist;
const percent = diff / dist / 2;
const offsetX = dx * percent;
const offsetY = dy * percent;
if (!s.p1.pinned) {
s.p1.x -= offsetX;
s.p1.y -= offsetY;
}
if (!s.p2.pinned) {
s.p2.x += offsetX;
s.p2.y += offsetY;
}
}
}
}
// 5. 渲染逻辑(这里为了简单直接用 DOM,实际生产用 Canvas)
function render() {
const { points, sticks } = worldRef.current;
// 这里只是伪代码,实际需要操作 DOM 节点
// 在真实场景中,我们会把 points 映射回 React 的 state,或者直接操作 Canvas
}
return <div className="physics-container" />;
}
看,这就是架构的艺术。useEffect 负责启动“后台线程”(虽然 JS 是单线程的,但 requestAnimationFrame 是浏览器帮我们切分的时间片)。worldRef 是我们的物理世界仓库。React 只负责在初始化时放进去东西,然后退后一步,让物理引擎自己去折腾。
第五部分:渲染——从 DOM 到 Canvas 的性能飞跃
上面的代码中,render 函数是空的。为什么?因为如果我们用 React 的 div 和 span 来渲染几千个质点和成千上万根约束,浏览器会当场去世。
React 的虚拟 DOM 很快,但它不是无所不能的。每帧去更新 1000 个 DOM 节点的位置,那是巨大的开销。
我们的解决方案:Canvas API。
Canvas 是位图的,它是像素级的。一旦画在 Canvas 上,它就是静态的,直到你下一帧擦掉它重新画。这非常符合物理引擎“每帧重绘”的特性。
我们需要一个 Canvas 组件。它不需要知道物理的细节,它只需要从 React 的状态(或者 Ref)中拿到当前的坐标,然后画线。
代码示例:React 驱动的 Canvas 渲染器
function ClothCanvas() {
const canvasRef = useRef(null);
const worldRef = useRef({ points: [], sticks: [] });
// ... (初始化逻辑同上,省略)
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// 处理高分屏模糊问题
const dpr = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
ctx.scale(dpr, dpr);
const animate = () => {
updatePhysics();
draw(ctx);
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, []);
function draw(ctx) {
const { points, sticks } = worldRef.current;
// 清空画布
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
// 绘制约束(线)
ctx.beginPath();
ctx.strokeStyle = '#00ffcc';
ctx.lineWidth = 1;
for (let i = 0; i < sticks.length; i++) {
const s = sticks[i];
ctx.moveTo(s.p1.x, s.p1.y);
ctx.lineTo(s.p2.x, s.p2.y);
}
ctx.stroke();
// 绘制点(可选,为了调试或视觉效果)
// for (let i = 0; i < points.length; i++) {
// const p = points[i];
// ctx.fillStyle = '#ff00cc';
// ctx.fillRect(p.x - 1, p.y - 1, 2, 2);
// }
}
return <canvas ref={canvasRef} style={{ position: 'fixed', top: 0, left: 0 }} />;
}
现在,我们拥有了速度。Canvas 的绘制速度非常快,它能在 16ms 内(60fps)完成成千上万次 lineTo 调用。
第六部分:交互——让布料听话
物理世界是死的,除非你给它施加外力。在 React 中,我们需要监听用户的输入,并将这些输入转化为物理世界的“力”。
想象一下,你有一只手(鼠标),你想去抓那块布。你的手就是一个移动的“质点”,它对布料上的点施加一个排斥力或引力。
代码示例:鼠标交互
function InteractiveCloth() {
const worldRef = useRef({ points: [], sticks: [] });
const mouseRef = useRef({ x: 0, y: 0, isDown: false });
// 监听鼠标事件
useEffect(() => {
const handleMouseMove = (e) => {
mouseRef.current.x = e.clientX;
mouseRef.current.y = e.clientY;
};
const handleMouseDown = () => mouseRef.current.isDown = true;
const handleMouseUp = () => mouseRef.current.isDown = false;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
// 在 updatePhysics 中添加交互逻辑
function updatePhysics() {
// ... (原有的物理更新代码)
// 鼠标交互逻辑
const mouse = mouseRef.current;
const radius = 50; // 鼠标影响范围
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned) continue;
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const distSq = dx * dx + dy * dy;
// 如果鼠标在范围内
if (distSq < radius * radius) {
const dist = Math.sqrt(distSq);
const force = (radius - dist) / radius; // 距离越近,力越大
if (mouse.isDown) {
// 如果鼠标按下,这是一个“抓取”力,直接移动点
p.x += dx * force * 0.5;
p.y += dy * force * 0.5;
// 注意:不更新 oldX,这样点就会“粘”在鼠标上
} else {
// 如果鼠标没按下,这是一个“排斥”力(像手推开布料)
p.x += dx * force * 0.1;
p.y += dy * force * 0.1;
}
}
}
// ... (约束求解)
}
}
这就是交互的魔力。通过监听 React 事件(或者原生事件,因为我们是在 Canvas 外部监听),我们将用户的行为注入到 worldRef 的状态中。物理引擎在下一帧计算时,就会读取到这个鼠标的位置,并计算力。
这就是声明式交互:你只需要描述“鼠标在哪里”,React(通过事件监听)帮你更新状态,物理引擎根据状态计算结果。
第七部分:高级特性——风力与撕裂
为了展示“复杂物理系统”的能力,我们不能只做简单的布料。让我们加点难度。
1. 风力系统
风不是静态的,它应该是一个力场。我们可以给每个点添加一个随机方向的力,并且让它随时间波动。
// 在 updatePhysics 中
const time = Date.now() * 0.001;
const windStrength = Math.sin(time) * 0.2 + 0.1; // 风力大小随时间变化
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (p.pinned) continue;
// 模拟风力:向右吹
p.x += windStrength;
p.y += Math.cos(time * 2 + p.x * 0.01) * 0.05; // 风有湍流
}
2. 布料撕裂
这是 Verlet 积分的另一个神技。如果你把约束的长度设得非常短,然后给布料一个巨大的冲击力,Verlet 算法本身不会“断裂”。但是,你可以写一个逻辑来检测“拉伸过度”。
如果两点之间的距离超过了原始长度的 1.5 倍,就把它们之间的连线断开(从 sticks 数组中移除)。
// 在约束求解循环中
for (let j = 0; j < sticks.length; j++) {
const s = sticks[j];
const dx = s.p2.x - s.p1.x;
const dy = s.p2.y - s.p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// 如果拉伸超过 20%
if (dist > s.length * 1.2) {
// 移除这个约束(撕裂!)
sticks.splice(j, 1);
j--; // 因为数组长度变了,索引要回退
} else {
// 正常约束求解...
}
}
这会让你的布料在剧烈运动时真的“破”了,这种反馈感是非常爽的。
第八部分:性能优化——不要让浏览器哭
当我们写完了这些代码,兴奋地打开浏览器,结果发现只有 10 FPS。
为什么?因为我们做了太多数学计算。
- Math.sqrt 是昂贵的: 在约束求解的循环里,我们每根线都调用了
Math.sqrt。在物理引擎里,为了性能,我们经常用dx*dx + dy*dy来代替dist,只有在最后需要真实距离时才开根号。 - 对象创建: 每一帧都在创建新的对象吗?不,我们复用了
points和sticks数组。这是关键。 - 约束迭代次数: 迭代次数越多,布料越硬越稳,但计算量是指数级增长的。对于布料,5 次迭代通常就够了。对于头发,可能需要 10 次。
React 优化的关键:
React 的虚拟 DOM Diff 算法虽然快,但在处理高频更新的 Canvas 时,它帮不上忙。因为 Canvas 是直接操作像素的,React 甚至不知道画布里画了什么。
所以,不要把 Canvas 当作 React 组件。把它当作一个“黑盒”组件。React 只负责初始化参数(如重力大小、风力强度、颜色),然后交给物理引擎去跑。
第九部分:总结——混合编程的哲学
通过今天的讲座,我们完成了什么?
我们构建了一个混合架构。
上层是 React,它是优雅的、声明式的、负责配置和交互的。
下层是 Verlet 积分,它是混乱的、命令式的、负责计算的。
我们利用 useRef 隔离了物理状态,防止它污染 React 的渲染树。
我们利用 requestAnimationFrame 赋予了物理世界独立的生命周期。
我们利用 Canvas API 赋予了它高性能的渲染能力。
这种架构不仅适用于布料,它同样适用于:
- 粒子系统(爆炸、烟雾、雪花)。
- 刚体碰撞(推箱子、箱子倒塌)。
- 流体模拟(虽然流体更复杂,但原理类似)。
React 驱动的物理引擎,不是让 React 去算物理,而是让 React 去管理物理的参数和生命周期。
当你下次看到 React 的 useState,不要只想到它用来存用户的名字。想到它,就像想到一个指挥家手中的指挥棒。指挥棒挥动(状态改变),舞台上的舞者(物理引擎)就会随之起舞。
这就是现代前端开发的魅力:我们不再仅仅是写 HTML/CSS,我们在编织数字的宇宙。
好了,今天的讲座到此结束。现在,去你的控制台里创建一个布料,狠狠地拽它一把,感受一下代码的弹性吧!