如何使用正向运动学在Fabric.js/JavaScript中实现带父子联动的2D骨架?
用正向运动学重写Fabric.js火柴人,实现父子节点联动旋转
核心思路
正向运动学(FK)的核心是建立骨骼节点的父子层级依赖:父节点的旋转、位移会传递给所有子节点,子节点基于父节点的坐标系计算自身的最终位置和角度。在Fabric.js中,我们可以通过自定义骨骼对象、递归更新子节点状态来实现这个效果。
实现步骤
- 自定义骨骼节点:继承Fabric.Rect(或Fabric.Line),扩展
parent(父节点)、children(子节点列表)、length(骨骼长度)、localAngle(相对于父节点的局部角度)属性,方便层级管理和FK计算。 - 构建火柴人骨骼层级:比如设置「躯干」为根节点,「大臂」「大腿」作为躯干的子节点,「小臂」「小腿」分别作为对应大臂、大腿的子节点,形成树状结构。
- 实现FK更新函数:写一个递归函数,当父节点的位置或角度变化时,计算子节点的起始坐标(父节点的末端位置),再根据父节点的世界角度+子节点局部角度,计算子节点的末端位置,最后递归更新子节点的所有后代。
- 绑定拖拽交互:给每个骨骼节点添加鼠标拖拽事件,拖拽结束时触发FK更新,同步所有子节点的状态。
代码示例
// 自定义骨骼类 class Bone extends fabric.Rect { constructor(options) { super(options); this.parent = options.parent || null; this.children = []; this.length = options.length || 50; this.localAngle = options.localAngle || 0; // 标记骨骼的末端点(用于子节点定位) this.endPoint = new fabric.Point(0, 0); } // 更新自身世界角度和末端点 updateWorldState() { if (this.parent) { // 父节点的世界角度 + 自身局部角度 = 自身世界角度 this.angle = this.parent.angle + this.localAngle; // 子节点起始点 = 父节点的末端点 const startX = this.parent.endPoint.x; const startY = this.parent.endPoint.y; // 计算自身末端点:起始点 + 长度 * 角度偏移 const rad = fabric.util.degreesToRadians(this.angle); this.left = startX; this.top = startY; this.endPoint.x = startX + this.length * Math.cos(rad); this.endPoint.y = startY + this.length * Math.sin(rad); } else { // 根节点直接用自身位置计算末端点 const rad = fabric.util.degreesToRadians(this.angle); this.endPoint.x = this.left + this.length * Math.cos(rad); this.endPoint.y = this.top + this.length * Math.sin(rad); } } } // 递归更新所有子节点 function updateFK(bone) { bone.updateWorldState(); bone.children.forEach(child => { updateFK(child); }); } // 初始化画布和火柴人 const canvas = new fabric.Canvas('canvas'); // 创建根节点:躯干 const torso = new Bone({ left: 200, top: 100, width: 10, height: 80, fill: '#333', length: 80, angle: 0 }); // 创建大臂(躯干的子节点) const upperArm = new Bone({ parent: torso, width: 8, height: 60, fill: '#333', length: 60, localAngle: -45 // 相对于躯干向左上方45度 }); torso.children.push(upperArm); // 创建小臂(大臂的子节点) const lowerArm = new Bone({ parent: upperArm, width: 6, height: 50, fill: '#333', length: 50, localAngle: 30 // 相对于大臂向右下方30度 }); upperArm.children.push(lowerArm); // 添加到画布 canvas.add(torso, upperArm, lowerArm); // 绑定拖拽事件:拖拽任意节点后更新FK canvas.on('object:modified', function(e) { const target = e.target; if (target instanceof Bone) { // 如果是子节点,更新其局部角度(相对于父节点) if (target.parent) { target.localAngle = target.angle - target.parent.angle; } // 从根节点开始递归更新所有节点 updateFK(torso); canvas.renderAll(); } });
关键细节
- 局部角度 vs 世界角度:子节点的
localAngle是相对于父节点的角度,angle是世界角度(父节点角度+局部角度),这样父节点旋转时,子节点会自动继承旋转偏移。 - 末端点计算:每个骨骼的末端点是子节点的起始点,必须基于父节点的世界坐标和角度计算,确保层级联动准确。
- 递归更新:必须从根节点开始递归更新,否则会出现子节点状态不同步的问题。
内容的提问来源于stack exchange,提问作者Venk
相关产品推荐
相关产品推荐

