Skip to content

Latest commit

 

History

History
778 lines (597 loc) · 18.7 KB

File metadata and controls

778 lines (597 loc) · 18.7 KB

约束动画系统开发补充文档

一、核心接口签名

1. EngineOptions

建议采用 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;
}

2. Channel

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 影响过大,建议仅在无外力且距离目标较近时启用。

3. Node

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;
}

4. Constraint

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;
}

5. Solver

export interface Solver {
  type: SolverType;
  solve(dt: number, substeps: number): void;
}

ForceSolver 生命周期。

  1. 每子步先清理通道约束力(force 字段,不清理 externalForce)。
  2. 执行 N 次约束迭代:每次遍历 constraints 调用 solve,累积约束力到 force 字段。
  3. 执行通道 step,同时使用 force(约束力)和 externalForce(外部力)。
  4. 在所有子步与迭代完成后,统一清除 externalForce

ProjectionSolver 生命周期(当前未实现)。

  1. 每子步先执行通道的预测积分,得到 x*
  2. 执行 N 次投影约束迭代,直接调整 value。
  3. 回写速度 v = (x_new - x_old) / dt

6. Renderer

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 参考规则。

  1. translate, rotate, scale 的顺序固定。
  2. rotate 使用 rad 输出。
  3. 每帧只写 transform 与 opacity。

7. Engine

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 用于测试
}

二、生命周期与事件顺序

1. 帧循环

  1. 计算 dt 并夹断。
  2. Read Phase:若 enableLayoutRead 开启,执行布局测量缓存。
  3. Solve Phase:按 substeps 拆分 dt,执行 solver.solve。
  4. Write Phase:renderer.render。
  5. Sleep 管理:满足阈值则休眠,收到 setState、overrideTarget、addForce、addImpulse、交互事件时唤醒。

2. 读写分离约束

若启用布局读取。

  1. Read Phase 只读 DOM。
  2. Write Phase 只写 DOM。
  3. 任何约束或渲染中不得调用 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 };
}

1) FollowConstraint

输入: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);
    }
  };
}

边界条件。

  1. A 或 B 休眠时若被约束影响,应唤醒。
  2. offset 可随状态或响应式参数变化。

2) AlignConstraint

将多个通道对齐到共同值。 建议两种模式:软对齐(力)与硬对齐(投影)。

软对齐。

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);
    }
  };
}

边界条件。

  1. 硬对齐更适合 ProjectionSolver。
  2. 多通道对齐可能导致能量注入,硬对齐需配合速度回写与阻尼。

3) DistanceConstraint

与 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);
    }
  };
}

4) BoundsConstraint

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);
      }
    }
  };
}

边界条件。

  1. soft 模式下若 kC 过大需增加 substeps。
  2. hard 模式下必须回写速度以避免穿透反复抖动。

5) SnapConstraint

吸附点集合。

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);
    }
  };
}

边界条件。

  1. points 在动态布局下需来自测量缓存。
  2. 吸附切换点可能造成跳动,可引入滞回 hysteresis 与锁定机制。

6) RatioConstraint

保持 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 与 SolveContext 实现要点

1. ChannelRef 解析

建议在 Engine 内构建索引表。

  1. 将 "NodeId.channelName" 映射到 Channel 实例。
  2. addConstraint 时预解析 endpoints,避免每帧字符串解析。

示例。

type ResolvedRef = { node: Node; name: string; ch: Channel };

2. SolveContext

ForceSolver 中。

  1. addForce 仅累积到 channel.force。
  2. project 不应在 ForceSolver 中使用或直接抛出异常。

ProjectionSolver 中。

  1. addForce 可忽略或用于辅助。
  2. project 直接修改 channel.value。

3. 休眠机制

实现策略。

  1. 每节点在每帧统计是否所有通道 isSleeping。
  2. 连续 sleepFrames 帧满足后 sleeping=true。
  3. 任何 target 变化或外力注入触发 wake。

五、最小可运行 Demo

1. 目录结构

  1. demo/ • index.html • main.ts • styles.css
  2. src/ • core/ • constraints/ • solvers/ • renderers/ • adapters/
  3. package.json
  4. tsconfig.json
  5. vite.config.ts

2. 构建方式

建议使用 Vite。

package.json 核心脚本。

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest"
  }
}

3. Demo 场景

场景目标。

  1. 两个方块 A 与 B。
  2. 状态 idle 与 focus。
  3. B.x 跟随 A.x + offset。
  4. A 可拖拽,松手后回弹到当前状态目标。
  5. 边界约束保证 A 不出容器。
  6. 吸附点在容器网格上,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);
});

4. 拖拽适配器接口

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 模式。

  1. pointermove 时设置 x.target 与 y.target。
  2. pointerup 时恢复到节点当前 state 的 target,或保持当前 target 由状态驱动。

force 模式。

  1. e = (pointerX - x.value)
  2. f = K e - C v
  3. addForce 注入

六、参数预设与调参指南

1. 通道预设

建议以用途分组并提供合理默认值。

  1. UI 位置 Position • mass 1 • stiffness 320 • damping 34 • vmax Infinity • friction 0 • epsilon 0.01

  2. UI 缩放 Scale • mass 1 • stiffness 520 • damping 44 • epsilon 0.001

  3. 透明度 Opacity • mass 1 • stiffness 420 • damping 60 • epsilon 0.001

  4. 旋转 Rotate • mass 1 • stiffness 260 • damping 26

2. 约束参数

  1. Follow kC: 400-900,cC: 40-90
  2. Bounds soft kC: 700-1400,cC: 60-120
  3. Snap strength: 800-2000,radius: 6-16,cC: 60-120

3. 调参规则

  1. 先调 damping 再调 stiffness。
  2. stiffness 提升会增加振荡风险,需要配合 substeps 或提高 damping。
  3. 拖拽模式下建议降低 stiffness 以提升跟手感,松手后恢复默认。

七、测试用例清单

1. Channel

  1. 初值等于目标时保持不动。
  2. 目标切换后收敛,误差单调或振荡受控。
  3. dt 夹断情况下不发散。
  4. vmax 生效。
  5. epsilon 贴合。

2. Constraint

  1. follow:随机初值下 B-A 收敛到 offset。
  2. bounds:越界后回到范围。
  3. snap:在 radius 内收敛到最近点。
  4. ratio:满足线性关系。

3. Engine

  1. step(dt) 确定性:相同输入序列得到相同输出。
  2. sleep:达到阈值后停止更新,唤醒后继续。

4. 性能基准

  1. 1,000 通道,200 约束,统计 solveMs。
  2. 吸附点数量增加时的复杂度曲线,建议对 points 做分桶或二分。

八、工程实现建议

  1. 预解析引用。 • addConstraint 时解析 ChannelRef,避免每帧字符串拆分。

  2. 约束分组。 • 将 snap、bounds、follow 分组,按稳定性与依赖顺序执行。

  3. 减少 GC。 • solve 不创建临时对象,使用局部标量。

  4. 数值健康检查。 • 在 dev 模式下检测 NaN 与 Infinity,并输出 nodeId 与 channel。

  5. 可视化调试。 • Overlay 展示 A-B 约束线、吸附点、边界框。

九、已知坑与规避

  1. 强吸附导致跳跃。 • 加入滞回锁定:进入吸附后保持锁定到该点,离开更大半径才释放。

  2. 多约束冲突导致抖动。 • 降低某些约束的 kC,或提升迭代次数。

  3. 布局测量与 transform 混用的坐标系不一致。 • 明确坐标系:内部 x,y 均为容器坐标,测量时将 rect 转换到容器原点。

  4. 投影修正后速度异常。 • 回写速度,并对速度做限制与阻尼。