关于3D 骨骼自定义动作动作的可行性
精华05/02214 浏览开发心得
用一个 AI 生成的 3D 美少女模型,探索运行时骨骼编辑、权重绘制和关键帧动画的完整技术路线。



起因:AI 生成的模型,动作不够用我用 AI 生成了一个 3D 美少女角色(Blue Archive 风格),自动绑骨后得到了 41 根标准人形骨骼。从动画库里挂了 52 个动作——待机、奔跑、攻击、技能,效果还不错。但很快遇到了问题:
- 想让她做个比心的手势?——没有手指骨骼
- 想让头发随动作飘动?——没有头发物理骨骼
- 想做一个库里没有的自定义姿势?——只能用现成动画,无法自由编辑


第一步:让骨骼动起来bone.animated:最关键的一行代码
直觉上,修改骨骼旋转很简单:lua
复制local skeleton = animModel:GetSkeleton()
local bone = skeleton:GetBone("Head")
bone.node.rotation = Quaternion(30, 0, 0) -- 低头 30 度
但实际运行发现——完全没效果。模型纹丝不动。原因是动画系统每帧都会把骨骼重置回动画数据定义的姿势。你在这帧改了,下一帧就被覆盖了。解决方法只需要一行:bone.animated = false -- 告诉动画系统:这根骨骼我自己管
加上这一行之后,动画系统会跳过这根骨骼,你手动设置的旋转就能保留了。旋转的正确姿势:增量叠加直接设置绝对旋转会丢失骨骼的初始绑定姿势,导致模型变形。正确做法是在初始姿势上叠加旋转增量:lua
复制bone.animated = false
-- 增量旋转(俯仰30度、偏航0度、翻滚0度)
local delta = Quaternion(30, 0, 0)
-- 叠加到初始姿势上
bone.node.rotation = delta * bone.initialRotation
bone.initialRotation 是模型在 T-Pose / A-Pose 时这根骨骼的旋转值。用增量乘以它,就是"在原来姿势的基础上再转一点"。做成交互式编辑器有了这个基础,就可以给每根骨骼配上三个滑块(俯仰、偏航、翻滚),实时拖拽调整:lua
复制-- 滑块回调
function OnSliderChanged(boneName, axis, value)
local bone = skeleton:GetBone(boneName)
bone.animated = false
-- 记录三轴旋转值
rotations[boneName][axis] = value
local r = rotations[boneName]
-- 应用
local delta = Quaternion(r[1], r[2], r[3])
bone.node.rotation = delta * bone.initialRotation
end
把 19 根主要骨骼分成 6 组(头部、躯干、左臂、右臂、左腿、右腿),每组点开选择具体骨骼,拖滑块就能实时看到模型变化。
第二步:缺少的骨骼怎么办?
41 根骨骼的清单:Root ─ Hip ─ Pelvis
├─ L_Thigh ─ L_Calf ─ L_Foot ─ L_ToeBase
├─ R_Thigh ─ R_Calf ─ R_Foot ─ R_ToeBase
└─ Waist ─ Spine01 ─ Spine02
├─ NeckTwist01 ─ NeckTwist02 ─ Head
├─ L_Clavicle ─ L_Upperarm ─ L_ForearmTwist ─ L_Forearm ─ L_Hand
└─ R_Clavicle ─ R_Upperarm ─ R_ForearmTwist ─ R_Forearm ─ R_Hand
有什么:头、颈、脊椎、肩、大臂、小臂、手掌、大腿、小腿、脚
没有什么:手指(5x3=15 根)、头发链、眼球、下巴、舌头运行时能加新骨骼吗?调研了引擎 API 后发现:Skeleton 没有 AddBone() 方法,不能在运行时添加新骨骼。但这不是死路——有三个变通方案:方案 A:复用空闲骨骼模型里有几根 twist 骨骼(NeckTwist02、L_ForearmTwist 等),它们在大多数动画中几乎不动。可以"借用"它们:
- 把 twist 骨骼的节点重新定位到手指关节位置
- 修改手指区域顶点的骨骼索引,让它们指向这些 twist 骨骼
- 这样旋转 twist 骨骼就能带动手指网格变形
复制local vb = geometry:GetVertexBuffer(0)
local data = vb:GetData() -- 读取全部顶点数据
-- 修改特定顶点的位置...
vb:SetDataRange(modifiedData, startVertex, count) -- 写回 GPU
引擎的 VertexBuffer 完整支持运行时读写,适合做捏脸、表情等简单变形。方案 C:离线重建模型在编辑器中设计完整的骨骼层级和权重,导出为新的 MDL 文件,重新加载。最完整但工程量最大。
第三步:权重绘制——让新骨骼真正生效不管用哪个方案,核心都需要修改顶点的骨骼权重。顶点权重的数据结构每个顶点存储了 4 组骨骼绑定数据:顶点 #1234:
BoneIndices: [12, 11, 0, 0] -- 绑定到第 12、11 号骨骼
BoneWeights: [0.7, 0.3, 0, 0] -- 影响权重(合计 = 1.0)
这意味着:这个顶点 70% 跟着第 12 号骨骼动,30% 跟着第 11 号骨骼动。读写权重的 APIlua
复制local vb = geometry:GetVertexBuffer(0)
-- 检查是否有权重数据
if vb:HasElement(SEM_BLENDWEIGHTS) then
local weightOffset = vb:GetElementOffset(SEM_BLENDWEIGHTS) -- 字节偏移
local indexOffset = vb:GetElementOffset(SEM_BLENDINDICES)
local data = vb:GetData() -- VectorBuffer
-- 逐顶点读取、修改、写回...
end
画刷选择顶点要修改权重,首先需要选中顶点。引擎支持三角面级别的射线检测:lua
复制local ray = camera:GetScreenRay(mouseX / screenW, mouseY / screenH)
local result = octree:RaycastSingle(ray, RAY_TRIANGLE_UV, 100.0)
if result then
local hitPos = result.position -- 命中点世界坐标
-- 找到命中点附近一定半径内的所有顶点
-- 批量修改它们的权重
end
权重归一化修改权重时必须保证每个顶点的 4 个权重之和 = 1.0:lua
复制function NormalizeWeights(w1, w2, w3, w4)
local sum = w1 + w2 + w3 + w4
if sum > 0 then
return w1/sum, w2/sum, w3/sum, w4/sum
end
return 1, 0, 0, 0
end
第四步:时间线动画——让姿势动起来单个姿势只是一帧画面。要做动画,需要时间线系统——在不同时间点设置关键帧,自动插值过渡。关键帧数据结构lua
复制-- 一个关键帧 = 某个时间点所有骨骼的旋转状态
local keyframe = {
time = 0.5, -- 秒
bones = {
Head = { pitch = 20, yaw = 0, roll = 0 },
L_Upperarm = { pitch = -45, yaw = 30, roll = 0 },
R_Upperarm = { pitch = -45, yaw = -30, roll = 0 },
-- ...更多骨骼
}
}
-- 一段动画 = 多个关键帧的序列
local animation = {
name = "挥手",
duration = 2.0, -- 总时长(秒)
loop = true,
keyframes = {
{ time = 0.0, bones = { ... } }, -- 起始姿势
{ time = 0.5, bones = { ... } }, -- 手举到最高
{ time = 1.0, bones = { ... } }, -- 手放回
{ time = 1.5, bones = { ... } }, -- 再举起
{ time = 2.0, bones = { ... } }, -- 回到起始(循环)
}
}
关键帧插值两个关键帧之间需要平滑过渡。旋转使用四元数球面插值(Slerp):lua
复制function LerpKeyframes(kf1, kf2, t)
-- t: 0.0 ~ 1.0 之间的进度
local result = {}
for boneName, rot1 in pairs(kf1.bones) do
local rot2 = kf2.bones[boneName]
if rot2 then
result[boneName] = {
pitch = rot1.pitch + (rot2.pitch - rot1.pitch) * t,
yaw = rot1.yaw + (rot2.yaw - rot1.yaw) * t,
roll = rot1.roll + (rot2.roll - rot1.roll) * t,
}
end
end
return result
end
实际应用中,对四元数做 Slerp 效果更好,避免万向锁:lua
复制local q1 = Quaternion(rot1.pitch, rot1.yaw, rot1.roll)
local q2 = Quaternion(rot2.pitch, rot2.yaw, rot2.roll)
local qResult = q1:Slerp(q2, t)
bone.node.rotation = qResult * bone.initialRotation
时间线播放器lua
复制local Timeline = {}
function Timeline:New(animation)
return {
anim = animation,
time = 0,
playing = false,
}
end
function Timeline:Update(dt)
if not self.playing then return end
self.time = self.time + dt
-- 循环处理
if self.anim.loop then
self.time = self.time % self.anim.duration
elseif self.time > self.anim.duration then
self.time = self.anim.duration
self.playing = false
end
-- 找到当前时间所在的两个关键帧
local kf1, kf2 = self:FindKeyframes(self.time)
if kf1 and kf2 then
-- 计算两帧之间的进度
local progress = 0
local span = kf2.time - kf1.time
if span > 0 then
progress = (self.time - kf1.time) / span
end
-- 插值并应用
local blended = LerpKeyframes(kf1, kf2, progress)
for boneName, rot in pairs(blended) do
ApplyBoneRotation(boneName, rot)
end
end
end
function Timeline:FindKeyframes(time)
local kfs = self.anim.keyframes
for i = 1, #kfs - 1 do
if time >= kfs[i].time and time < kfs[i + 1].time then
return kfs[i], kfs[i + 1]
end
end
-- 最后一帧
return kfs[#kfs - 1], kfs[#kfs]
end
时间线编辑器 UI完整的时间线编辑器需要这些交互元素:┌─────────────────────────────────────────────────┐
│ [播放] [暂停] [停止] 时间: 0.50s / 2.00s │
├─────────────────────────────────────────────────┤
│ 时间轴 |----◆--------◆--------◆--------◆----| │
│ 0.0 0.5 1.0 1.5 2.0 │
├─────────────────────────────────────────────────┤
│ 骨骼轨道: │
│ Head ──◆──────────◆──────────◆── │
│ L_Upperarm ──◆────◆─────◆────◆─────◆── │
│ R_Upperarm ──◆────◆─────◆────◆─────◆── │
│ Spine01 ──◆──────────────────────◆── │
├─────────────────────────────────────────────────┤
│ [添加关键帧] [删除关键帧] [复制帧] [导出JSON] │
└─────────────────────────────────────────────────┘
核心交互:
- 拖动时间指针:预览任意时间点的姿势
- 点击"添加关键帧":将当前骨骼编辑器的姿势记录到当前时间点
- 选中关键帧菱形标记:跳到该帧并加载姿势到编辑器
- 播放:自动按时间推进,实时插值显示动画
第五步:数据导出——连接 2D 世界
做好的 3D 动画,还可以投影到 2D 用于 Live2D 风格的编辑器。骨骼投影在正交相机下,将每根骨骼的 3D 世界坐标投影为 2D 屏幕坐标:lua
复制function ProjectBones(camera, skeleton)
local frame = {}
for i = 0, skeleton:GetNumBones() - 1 do
local bone = skeleton:GetBone(i)
if bone.node then
local worldPos = bone.node.worldPosition
local screenPos = camera:WorldToScreenPoint(worldPos)
frame[bone.name] = {
x = screenPos.x,
y = screenPos.y,
depth = screenPos.z, -- 保留深度信息,2D 端可选用
}
end
end
return frame
end
逐帧录制播放时间线动画的同时,每帧记录所有骨骼的 2D 投影:lua
复制function RecordAnimation(timeline, camera, skeleton, fps)
local frames = {}
local dt = 1.0 / fps
local time = 0
while time <= timeline.anim.duration do
timeline.time = time
timeline:ApplyFrame() -- 应用当前帧的骨骼姿势
frames[#frames + 1] = {
time = time,
bones = ProjectBones(camera, skeleton),
}
time = time + dt
end
return frames
end
导出格式json
复制{
"name": "挥手",
"fps": 30,
"duration": 2.0,
"boneCount": 41,
"frames": [
{
"time": 0.0,
"bones": {
"Head": { "x": 0.50, "y": 0.15, "depth": 0.42 },
"L_Hand": { "x": 0.35, "y": 0.55, "depth": 0.38 },
"R_Hand": { "x": 0.65, "y": 0.55, "depth": 0.38 }
}
},
{
"time": 0.033,
"bones": { ... }
}
]
}
2D 编辑器拿到这份数据后,就可以把骨骼点映射到 2D 人偶的控制点上,驱动 2D 变形动画——而 2D 端可以自由换装、换发型,不受 3D 模型限制。
技术总结已验证可行能力关键 API复杂度旋转现有骨骼bone.animated = false + bone.node.rotation低实时预览滑块 + 每帧应用旋转低读写顶点权重VertexBuffer:GetData() / SetData()中射线拾取网格Octree:RaycastSingle(ray, RAY_TRIANGLE_UV)中骨骼 2D 投影camera:WorldToScreenPoint()低需要实现功能依赖复杂度关键帧时间线数据结构 + 四元数 Slerp 插值中时间线编辑器 UI可拖拽时间轴 + 关键帧标记中高权重画刷射线拾取 + 顶点范围筛选 + 权重归一化高复用空闲骨骼做手指骨骼重定位 + 权重修改中导出新 MDL 文件MDL 二进制格式写入高推荐路线阶段 1 ── 骨骼旋转编辑器(已完成框架)
用滑块实时调整 19 根骨骼的旋转
阶段 2 ── 关键帧时间线
记录多个姿势 → 自动插值 → 播放自定义动画
阶段 3 ── 复用空闲骨骼
借用 twist 骨骼做手指 → 修改顶点权重
阶段 4 ── 权重画刷编辑器
射线选择网格区域 → 批量修改骨骼权重
阶段 5 ── 3D→2D 动画导出
正交投影 → 逐帧录制 → JSON 导出给 Live2D 编辑器
最后从"AI 生成一个 3D 模型"开始,到能在运行时编辑骨骼、自定义动画、甚至导出 2D 变形数据——这条技术路线的每一步在引擎层面都是可行的。核心就一句话:骨骼是数据,权重是数据,动画也是数据——数据都能改。区别只在于改哪层数据、用什么工具改、以及愿意投入多少工程量。



