建议采用 TypeScript 类型定义以提升开发效率。
export type SolverType = "force" | "projection";
export interface EngineOptions {
dtClamp?: number; // 默认 0.033
substeps?: number; // 默认 3
iterations?: number; // 约束迭代次数,ForceSolver 默认 1-3,ProjectionSolver 默认 3-8
solverType?: SolverType;// 默认 "force"(注意:当前仅实现了 force 求解器)
sleepPositionEps?: number; // 默认 0.01
sleepVelocityEps?: number; // 默认 0.01
sleepFrames?: number; // 连续多少帧满足阈值后休眠,默认 10
enableLayoutRead?: boolean; // 默认 false,预留选项,未来用于 Read Phase 读取布局
renderer?: Renderer; // 默认 DOMRenderer
onFrameStats?: (stats: FrameStats) => void; // 性能统计钩子
}
export interface FrameStats {
now: number;
dt: number;
substeps: number;
iterations: number;
nodeCount: number;
channelCount: number;
constraintCount: number;
solveMs: number;
renderMs: number;
}export interface ChannelParams {
mass?: number; // m (必须 > 0)
stiffness?: number; // k
damping?: number; // c
vmax?: number; // 速度上限
friction?: number; // 0-1,速度衰减比例
epsilon?: number; // 贴合阈值
}
export class Channel {
value: number;
target: number;
v: number;
force: number; // 约束力,每次迭代清零
externalForce: number; // 外部力(如拖拽),每帧清零
params: Required<ChannelParams>;
constructor(initial: number, params?: ChannelParams);
setTarget(x: number): void;
addForce(f: number): void;
addImpulse(j: number): void; // v += j / m
step(dt: number): void; // 半隐式欧拉
snapToTarget(): void; // value=target, v=0
isSleeping(posEps: number, velEps: number): boolean;
}注意: Channel 现在有两个力字段:
force: 约束力,在每次约束迭代前被清零。externalForce: 外部力(如拖拽适配器),在每帧所有子步与迭代完成后由Solver统一清零。
这样设计使得外部力可以在整个约束求解过程中(跨子步与多次迭代)保持有效。
Channel.step 的参考实现。
step(dt: number) {
const m = this.params.mass;
const k = this.params.stiffness;
const c = this.params.damping;
const x = this.value;
const v = this.v;
const spring = k * (this.target - x);
const damper = -c * v;
const a = (spring + damper + this.force + this.externalForce) / m;
let v2 = v + a * dt;
const vmax = this.params.vmax;
if (v2 > vmax) v2 = vmax;
if (v2 < -vmax) v2 = -vmax;
// friction 为 0..1,建议在没有外力且接近目标时增强
const fr = this.params.friction;
v2 *= (1 - fr);
const x2 = x + v2 * dt;
this.v = v2;
this.value = x2;
this.force = 0;
// epsilon 贴合
const eps = this.params.epsilon;
if (Math.abs(this.target - this.value) < eps && Math.abs(this.v) < eps) {
this.snapToTarget();
}
}注意:上面 friction 行为只是一个可行默认实现。若 friction 影响过大,建议仅在无外力且距离目标较近时启用。
export type Pose = Partial<Record<string, number>>;
export interface NodeInit {
id: string;
element?: HTMLElement;
initialPose?: Pose;
channelParams?: Partial<Record<string, ChannelParams>>;
}
export class Node {
id: string;
element?: HTMLElement;
channels: Map<string, Channel>;
states: Map<string, Pose>;
private sleepCounter: number;
private sleeping: boolean;
constructor(init: NodeInit);
getChannel(name: string): Channel;
ensureChannel(name: string, initial?: number, params?: ChannelParams): Channel;
defineState(name: string, pose: Pose): void;
setState(name: string, opts?: { merge?: boolean }): void; // merge true(默认)仅覆盖 pose 中的键;false 时会将所有通道目标设为当前值后再应用 pose
overrideTarget(pose: Pose): void;
isSleeping(posEps: number, velEps: number, sleepFrames: number): boolean;
wake(): void;
}export interface SolveContext {
get(channelRef: ChannelRef): Channel;
addForce(ch: ChannelRef, f: number): void;
project(ch: ChannelRef, newValue: number): void;
}
export type ChannelRef = string; // 例如 "A.x" 或内部 id
export interface Constraint {
id: string;
type: string;
solve(ctx: SolveContext, dt: number): void;
}export interface Solver {
type: SolverType;
solve(dt: number, substeps: number): void;
}ForceSolver 生命周期。
- 每子步先清理通道约束力(force 字段,不清理 externalForce)。
- 执行 N 次约束迭代:每次遍历 constraints 调用 solve,累积约束力到 force 字段。
- 执行通道 step,同时使用 force(约束力)和 externalForce(外部力)。
- 在所有子步与迭代完成后,统一清除
externalForce。
ProjectionSolver 生命周期(当前未实现)。
- 每子步先执行通道的预测积分,得到 x*
- 执行 N 次投影约束迭代,直接调整 value。
- 回写速度 v = (x_new - x_old) / dt
export interface Renderer {
prepare?(engine: Engine): void;
render(engine: Engine): void;
}更新说明:
- 自 v0.2 起,
willChange: transform, opacity的设置已从渲染器准备阶段移至Engine.registerNode()中自动处理。 Renderer.prepare()目前变为可选方法。如果自定义渲染器需要进行全局初始化(例如创建共用的 Canvas 上下文或注入全局样式),仍可实现此方法。
export class DOMRenderer implements Renderer {
render(engine: Engine): void;
}DOMRenderer.render 参考规则。
- translate, rotate, scale 的顺序固定。
- rotate 使用 rad 输出。
- 每帧只写 transform 与 opacity。
export class Engine {
options: Required<EngineOptions>;
nodes: Map<string, Node>;
constraints: Constraint[];
private running: boolean;
private lastNow: number;
constructor(options?: EngineOptions);
registerNode(init: NodeInit): Node;
getNode(id: string): Node;
defineState(nodeId: string, stateName: string, pose: Pose): void;
setState(nodeId: string, stateName: string, opts?: { merge?: boolean }): void;
overrideTarget(nodeId: string, pose: Pose): void;
addConstraint(c: Constraint): void;
removeConstraint(id: string): void;
start(): void;
stop(): void;
step(dt: number): void; // 确定性 stepping 用于测试
}- 计算 dt 并夹断。
- Read Phase:若 enableLayoutRead 开启,执行布局测量缓存。
- Solve Phase:按 substeps 拆分 dt,执行 solver.solve。
- Write Phase:renderer.render。
- Sleep 管理:满足阈值则休眠,收到 setState、overrideTarget、addForce、addImpulse、交互事件时唤醒。
若启用布局读取。
- Read Phase 只读 DOM。
- Write Phase 只写 DOM。
- 任何约束或渲染中不得调用 getBoundingClientRect。
本节提供可直接实现的伪代码模板。
function clamp(x: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, x));
}
function nearest(points: number[], x: number) {
let best = points[0];
let bestD = Math.abs(x - best);
for (let i = 1; i < points.length; i++) {
const d = Math.abs(x - points[i]);
if (d < bestD) { bestD = d; best = points[i]; }
}
return { p: best, d: bestD };
}输入:A 与 B 的同类通道,保持 B = A + offset。
export function follow(opts: {
id?: string;
a: ChannelRef;
b: ChannelRef;
offset: number;
kC: number;
cC: number;
}): Constraint {
const id = opts.id ?? `follow:${opts.a}->${opts.b}`;
return {
id,
type: "follow",
solve(ctx, dt) {
const A = ctx.get(opts.a);
const B = ctx.get(opts.b);
const e = (B.value - A.value) - opts.offset;
const relV = (B.v - A.v);
const f = -opts.kC * e - opts.cC * relV;
ctx.addForce(opts.b, f);
ctx.addForce(opts.a, -f);
}
};
}边界条件。
- A 或 B 休眠时若被约束影响,应唤醒。
- offset 可随状态或响应式参数变化。
将多个通道对齐到共同值。 建议两种模式:软对齐(力)与硬对齐(投影)。
软对齐。
export function alignSoft(opts: {
id?: string;
channels: ChannelRef[];
kC: number;
cC: number;
}): Constraint {
const id = opts.id ?? `alignSoft:${opts.channels.join(",")}`;
return {
id,
type: "alignSoft",
solve(ctx, dt) {
// 选择锚点为平均值,也可用第一个通道作为锚
let avg = 0;
for (const r of opts.channels) avg += ctx.get(r).value;
avg /= opts.channels.length;
// 对每个通道施加 toward avg 的力
for (const r of opts.channels) {
const ch = ctx.get(r);
const e = ch.value - avg;
const f = -opts.kC * e - opts.cC * ch.v;
ctx.addForce(r, f);
}
}
};
}硬对齐。
export function alignHard(opts: {
id?: string;
channels: ChannelRef[];
}): Constraint {
const id = opts.id ?? `alignHard:${opts.channels.join(",")}`;
return {
id,
type: "alignHard",
solve(ctx, dt) {
let avg = 0;
for (const r of opts.channels) avg += ctx.get(r).value;
avg /= opts.channels.length;
for (const r of opts.channels) ctx.project(r, avg);
}
};
}边界条件。
- 硬对齐更适合 ProjectionSolver。
- 多通道对齐可能导致能量注入,硬对齐需配合速度回写与阻尼。
与 Follow 形式一致,只是 offset 表示距离目标。
export function distance(opts: {
id?: string;
a: ChannelRef;
b: ChannelRef;
d: number;
kC: number;
cC: number;
}): Constraint {
const id = opts.id ?? `distance:${opts.a}<->${opts.b}`;
return {
id,
type: "distance",
solve(ctx, dt) {
const A = ctx.get(opts.a);
const B = ctx.get(opts.b);
const e = (B.value - A.value) - opts.d;
const relV = (B.v - A.v);
const f = -opts.kC * e - opts.cC * relV;
ctx.addForce(opts.b, f);
ctx.addForce(opts.a, -f);
}
};
}soft 模式为恢复力,hard 模式为投影。
export function bounds(opts: {
id?: string;
ch: ChannelRef;
min: number;
max: number;
mode: "soft" | "hard";
kC?: number;
cC?: number;
}): Constraint {
const id = opts.id ?? `bounds:${opts.ch}`;
return {
id,
type: "bounds",
solve(ctx, dt) {
const ch = ctx.get(opts.ch);
if (opts.mode === "hard") {
const v = clamp(ch.value, opts.min, opts.max);
if (v !== ch.value) ctx.project(opts.ch, v);
return;
}
const kC = opts.kC ?? 800;
const cC = opts.cC ?? 60;
if (ch.value < opts.min) {
const e = ch.value - opts.min;
const f = -kC * e - cC * ch.v;
ctx.addForce(opts.ch, f);
} else if (ch.value > opts.max) {
const e = ch.value - opts.max;
const f = -kC * e - cC * ch.v;
ctx.addForce(opts.ch, f);
}
}
};
}边界条件。
- soft 模式下若 kC 过大需增加 substeps。
- hard 模式下必须回写速度以避免穿透反复抖动。
吸附点集合。
export function snap(opts: {
id?: string;
ch: ChannelRef;
points: number[];
radius: number;
strength: number;
cC: number;
}): Constraint {
const id = opts.id ?? `snap:${opts.ch}`;
return {
id,
type: "snap",
solve(ctx, dt) {
const ch = ctx.get(opts.ch);
if (opts.points.length === 0) return;
const { p, d } = nearest(opts.points, ch.value);
if (d > opts.radius) return;
const e = ch.value - p;
const f = -opts.strength * e - opts.cC * ch.v;
ctx.addForce(opts.ch, f);
}
};
}边界条件。
- points 在动态布局下需来自测量缓存。
- 吸附切换点可能造成跳动,可引入滞回 hysteresis 与锁定机制。
保持 B = a A + b。
export function ratio(opts: {
id?: string;
A: ChannelRef;
B: ChannelRef;
a: number;
b: number;
kC: number;
cC: number;
}): Constraint {
const id = opts.id ?? `ratio:${opts.B}=${opts.a}*${opts.A}+${opts.b}`;
return {
id,
type: "ratio",
solve(ctx, dt) {
const A = ctx.get(opts.A);
const B = ctx.get(opts.B);
const desired = opts.a * A.value + opts.b;
const e = B.value - desired;
// 速度项按比例近似
const relV = B.v - opts.a * A.v;
const f = -opts.kC * e - opts.cC * relV;
ctx.addForce(opts.B, f);
ctx.addForce(opts.A, -f * opts.a);
}
};
}建议在 Engine 内构建索引表。
- 将 "NodeId.channelName" 映射到 Channel 实例。
- addConstraint 时预解析 endpoints,避免每帧字符串解析。
示例。
type ResolvedRef = { node: Node; name: string; ch: Channel };ForceSolver 中。
- addForce 仅累积到 channel.force。
- project 不应在 ForceSolver 中使用或直接抛出异常。
ProjectionSolver 中。
- addForce 可忽略或用于辅助。
- project 直接修改 channel.value。
实现策略。
- 每节点在每帧统计是否所有通道 isSleeping。
- 连续 sleepFrames 帧满足后 sleeping=true。
- 任何 target 变化或外力注入触发 wake。
- demo/ • index.html • main.ts • styles.css
- src/ • core/ • constraints/ • solvers/ • renderers/ • adapters/
- package.json
- tsconfig.json
- vite.config.ts
建议使用 Vite。
package.json 核心脚本。
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
}
}场景目标。
- 两个方块 A 与 B。
- 状态 idle 与 focus。
- B.x 跟随 A.x + offset。
- A 可拖拽,松手后回弹到当前状态目标。
- 边界约束保证 A 不出容器。
- 吸附点在容器网格上,A 靠近时吸附。
main.ts 伪代码。
const engine = new Engine({ substeps: 3, solverType: "force" });
engine.registerNode({ id: "A", element: elA, initialPose: { x: 60, y: 80, opacity: 1 } });
engine.registerNode({ id: "B", element: elB, initialPose: { x: 220, y: 80, opacity: 1 } });
engine.defineState("A", "idle", { x: 60, y: 80, scaleX: 1, scaleY: 1, opacity: 1 });
engine.defineState("A", "focus", { x: 90, y: 60, scaleX: 1.2, scaleY: 1.2, opacity: 1 });
engine.defineState("B", "idle", { x: 220, y: 80, opacity: 1 });
engine.defineState("B", "focus", { x: 280, y: 100, opacity: 0.8 });
engine.addConstraint(follow({ a: "A.x", b: "B.x", offset: 140, kC: 600, cC: 50 }));
engine.addConstraint(bounds({ ch: "A.x", min: 0, max: 420, mode: "soft", kC: 900, cC: 80 }));
engine.addConstraint(bounds({ ch: "A.y", min: 0, max: 240, mode: "soft", kC: 900, cC: 80 }));
const grid = Array.from({ length: 9 }, (_, i) => i * 60);
engine.addConstraint(snap({ ch: "A.x", points: grid, radius: 10, strength: 1200, cC: 80 }));
engine.addConstraint(snap({ ch: "A.y", points: grid, radius: 10, strength: 1200, cC: 80 }));
attachPointerDrag(engine, "A", { mode: "target" });
engine.setState("A", "idle");
engine.setState("B", "idle");
engine.start();
document.body.addEventListener("click", () => {
const next = toggle ? "idle" : "focus";
toggle = !toggle;
engine.setState("A", next);
engine.setState("B", next);
});export interface DragOptions {
mode: "target" | "force";
pickOffset?: boolean;
pointerCapture?: boolean;
forceK?: number;
forceC?: number;
}
export function attachPointerDrag(engine: Engine, nodeId: string, opts: DragOptions): () => void;target 模式。
- pointermove 时设置 x.target 与 y.target。
- pointerup 时恢复到节点当前 state 的 target,或保持当前 target 由状态驱动。
force 模式。
- e = (pointerX - x.value)
- f = K e - C v
- addForce 注入
建议以用途分组并提供合理默认值。
-
UI 位置 Position • mass 1 • stiffness 320 • damping 34 • vmax Infinity • friction 0 • epsilon 0.01
-
UI 缩放 Scale • mass 1 • stiffness 520 • damping 44 • epsilon 0.001
-
透明度 Opacity • mass 1 • stiffness 420 • damping 60 • epsilon 0.001
-
旋转 Rotate • mass 1 • stiffness 260 • damping 26
- Follow kC: 400-900,cC: 40-90
- Bounds soft kC: 700-1400,cC: 60-120
- Snap strength: 800-2000,radius: 6-16,cC: 60-120
- 先调 damping 再调 stiffness。
- stiffness 提升会增加振荡风险,需要配合 substeps 或提高 damping。
- 拖拽模式下建议降低 stiffness 以提升跟手感,松手后恢复默认。
- 初值等于目标时保持不动。
- 目标切换后收敛,误差单调或振荡受控。
- dt 夹断情况下不发散。
- vmax 生效。
- epsilon 贴合。
- follow:随机初值下 B-A 收敛到 offset。
- bounds:越界后回到范围。
- snap:在 radius 内收敛到最近点。
- ratio:满足线性关系。
- step(dt) 确定性:相同输入序列得到相同输出。
- sleep:达到阈值后停止更新,唤醒后继续。
- 1,000 通道,200 约束,统计 solveMs。
- 吸附点数量增加时的复杂度曲线,建议对 points 做分桶或二分。
-
预解析引用。 • addConstraint 时解析 ChannelRef,避免每帧字符串拆分。
-
约束分组。 • 将 snap、bounds、follow 分组,按稳定性与依赖顺序执行。
-
减少 GC。 • solve 不创建临时对象,使用局部标量。
-
数值健康检查。 • 在 dev 模式下检测 NaN 与 Infinity,并输出 nodeId 与 channel。
-
可视化调试。 • Overlay 展示 A-B 约束线、吸附点、边界框。
-
强吸附导致跳跃。 • 加入滞回锁定:进入吸附后保持锁定到该点,离开更大半径才释放。
-
多约束冲突导致抖动。 • 降低某些约束的 kC,或提升迭代次数。
-
布局测量与 transform 混用的坐标系不一致。 • 明确坐标系:内部 x,y 均为容器坐标,测量时将 rect 转换到容器原点。
-
投影修正后速度异常。 • 回写速度,并对速度做限制与阻尼。