关于3D 骨骼自定义动作动作的可行性

精华05/02214 浏览开发心得
用一个 AI 生成的 3D 美少女模型,探索运行时骨骼编辑、权重绘制和关键帧动画的完整技术路线。
horizontal linehorizontal line
video-5639523
起因:AI 生成的模型,动作不够用我用 AI 生成了一个 3D 美少女角色(Blue Archive 风格),自动绑骨后得到了 41 根标准人形骨骼。从动画库里挂了 52 个动作——待机、奔跑、攻击、技能,效果还不错。但很快遇到了问题:
  • 想让她做个比心的手势?——没有手指骨骼
  • 想让头发随动作飘动?——没有头发物理骨骼
  • 想做一个库里没有的自定义姿势?——只能用现成动画,无法自由编辑
horizontal linehorizontal line
第一步:让骨骼动起来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 等),它们在大多数动画中几乎不动。可以"借用"它们:
  1. 把 twist 骨骼的节点重新定位到手指关节位置
  2. 修改手指区域顶点的骨骼索引,让它们指向这些 twist 骨骼
  3. 这样旋转 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 变形数据——这条技术路线的每一步在引擎层面都是可行的。核心就一句话:骨骼是数据,权重是数据,动画也是数据——数据都能改。区别只在于改哪层数据、用什么工具改、以及愿意投入多少工程量。
猜你想搜
taptap 制造3d骨骼自定义
11
4
2