Skip to content

契约补充:3D 渲染出帧路线的角色资产与播放字段(Refs #63 #53 #64 #70) #81

Description

@johnnyzhang-eng

背景

本路线指「3D 建模 → 绑骨 → 套动作 → 渲染出 2D 序列帧」(下文简称渲染路线,与既有的逐帧路线并列)。该路线已跑通并产出一套完整角色资源,但这套产物在当前后端契约里没有落点

当前基线(#64 / #70 已合并):

Character
├── reference_image_url
└── character_data (JSONB)
    └── outfits[] {id, name, description, preview_url}
        └── actions[] {id, type, name, loop, fps, frame_count}
            └── frames[] {index, image_url, duration_ms}

这套结构围绕逐帧路线建立,渲染路线的产物有三类数据无处可放:

  1. 引擎播放必需的逐帧几何量(地面锚、位移轨、时长、图集)——缺了引擎播得出来但播不对;
  2. 3D 源资产(模型、骨架规范、武器挂点、渲染参数)——缺了每补一个动作都要把前四段重做一遍;
  3. 产物来源(任务生成 vs 外部导入)——存量资产由人工产出,且部分 provider 尚未接入,契约必须能表达「该资产不由某个 task 产出」。

本 Issue 只做加法:新增字段一律可选,新增枚举一律追加成员,既有字段语义与既有调用方均不受影响。

本路线的真实形态

全链路可编程,无人工步骤。除第 1 段外,3D 相关各段均由腾讯云混元生3D
ai3d.tencentcloudapi.com · Version 2025-05-13)提供接口:

实现 状态
1 母版 T-pose 图 图像模型 i2i(有 prompt 门禁) 已跑通
2 图生 3D SubmitHunyuanTo3DProJob / QueryHunyuanTo3DProJob 接口可用,适配器待接入;存量产物按外部导入处理
3 减面 + 导出 无头 Blender(本地) 已跑通。云端接口有 ≤60MB 上限,高模必须本地先减面
4 自动绑骨蒙皮 SubmitAutoRiggingJob / DescribeAutoRiggingJob 已实测跑通,全自动
5 动作片段 同上接口的 MotionType 参数(48 个预设动作) 已实测跑通,全自动
6 3D→2D 截帧 three.js + 无头浏览器,确定性取样(本地) 已跑通
7 剪影门禁 + 图集打包 Python(本地) 已跑通

云端同族接口另有 SubmitHunyuanTo3DMotionJob(文生动作)、SubmitReduceFaceJob(智能拓扑)、
SubmitHunyuanTo3DUVJob(UV 展开)、Convert3DFormat(格式转换)可选用。

第 4/5 段于 2026-08-03 实测跑通:5 次调用全部成功,单次 40–60 秒;异步提交 + 轮询
(JobId 有效期 24h,Status 取 RUN / DONE / FAIL)。原先依赖第三方网页版人工操作的断点
就此消除。实测结论见本 Issue 评论区,其中四点直接影响本契约的字段设计
(骨架命名取值、位移数据来源、clip_ref 需带区间、输入端硬约束)。

第 2 段的 provider 适配器本期仍留桩(NotImplementedError),契约按已接入的形态设计。

输入端硬约束(写进契约的依据)

绑骨接口对输入有三条约束,违反不会报错,而是产出错误结果

  1. 格式 FBX / GLB,≤60MB。生产模型实测 87MB,必须先本地减面 → model_3d.triangles 作为门禁值的依据。
  2. 须 A-Pose 或 T-Pose。
  3. 不得包含人体以外的组件(武器、配件)。实测送入带武器的模型后,武器被错误绑权重、动画中乱甩。
    我们的武器走刚体挂件(绑完骨挂到手骨、不进网格)天然合规 —— 这是 sockets 必须入契约、
    而不能把武器烘进网格的直接依据。

实测同时验证了 model_3d.skeleton_conventionsockets 的必要性:更换绑骨方案确实改变了骨架命名
(无命名空间前缀、骨数不同),靠这两个字段即可定位受影响范围——本次实测据此判定挂点参数可复用、无需重标定。


目标

让渲染路线与逐帧路线的产物共用同一套播放契约,且消费端按契约播放即正确:不脚陷地、不打滑、不二段跳。


一、统一单位口径(先定这个,否则字段无法自洽)

所有长度、位移、速度一律以「角色总高 = 1.0」为单位,不使用像素。

角度单位:度(deg),欧拉角按 XYZ 顺序。


二、提议字段

A. 动作层(character_data.outfits[].actions[])——两条路线通用

这一组不是渲染路线专属,逐帧路线同样需要,因此加在通用层,而非 3D 子对象里。

字段 类型 语义与必要性
anchor_y float 地面锚点,相对帧高的比例 [0,1]。绘制时 y = 地面线 − anchor_y × 当前帧高。各动作最深脚点不同,是渲染产物的几何事实,无法从 frames[] 推出。实测:五个动作统一取 idle 的锚,走路时脚陷地约 2.5% 帧高。归一化后满分辨率与图集共用一个值
root_motion list[[float, float]] | None 逐帧位移 (dx, dy),相对首帧,y 向上为正,单位为角色总高。长度 == 帧数。承接 #63 的论证:位移烘进像素则消费端无法控制悬空时长,抽成轨道后可由物理驱动。
move_speed_h_per_s float | None 建议施加的移动速度(身高/秒)。与 root_motion 是语义差异而非数值差异:前者是产品建议值,后者是片段实测值,二者允许不同——跳跃片段自带约 2.66 身高的前冲,但产品选择播放时不额外推进,否则叠加成两倍。实测 walk 1.137、run 3.023。
duration_s float 一轮精确时长。既有 fps 是导出量而非权威:idle 只取真循环周期 2.05s / 16 帧(等效 7.8fps),整段 10 秒采 16 帧会退化成幻灯片。既有 fps 不动,本字段为权威口径,冲突时以本字段为准。
atlas {file, cols, rows, scale} | None 图集产物。scale 相对 render_profile.frame_size,单格尺寸由二者推导,不重复存。只给散帧 URL 会让每个接入方各自重排一遍。
derivation "image_frames" | "render_3d" 该动作由哪条路线派生。放在动作层而非角色层:同一角色的不同动作会走不同路线(连续位移动作走渲染路线,需要单帧可编辑的离散姿势走逐帧路线),角色层只提供默认值。枚举值需与 #53 的分流命名保持一致。
clip_ref str | None 来源动作片段 ID,用于溯源与素材授权追踪。

主动交出的可删候选move_speed_h_per_s 数值上可由 root_motionduration_s 推导,保留它的唯一理由是上表所述语义差异。若评审认为「建议值应由消费端决定」,可删,代价是每个接入方各自实现一遍跳跃前冲的取舍。

B. 角色层(character_data)——渲染路线特有

model_3d: {
  url                    str            模型文件地址
  format                 "glb" | "fbx"
  triangles              int            三角面数
  has_skin               bool           是否已绑骨
  skeleton_convention    str            骨架命名规范,枚举收敛:mixamorig / plain_humanoid
  normalized             {height: 1.0, feet_y: 0.0, centered: true}
  source                 "generated" | "imported"
}

render_profile: {
  view                   "side" | "three_quarter"
  faces                  "right"        帧朝向,见口径三
  material               str            渲染材质模式,如 "cel"
  frame_size             [int, int]     满分辨率帧格,所有动作共用
}

sockets: {
  <挂点名>: {
    bone                 str            挂载骨骼名,如 "mixamorig:RightHand"
    position             [f, f, f]      骨骼局部空间坐标,单位=角色总高
    offset_along_axis    float          挂件沿自身 +Y 的补偿量
    rotation_euler_deg   [f, f, f]      XYZ 顺序,单位度
  }
}

必要性逐项:

  • model_3d:本路线的一切从它派生——补动作、换武器、改渲染都要回到模型;不存则每次重跑前四段(每段都是分钟级异步任务,且第 2 段按次计费)。triangles 单列是因为它是门禁值:绑骨服务对输入有面数与文件体积约束(实测 ≤60MB),高模必须先本地减面才能提交。skeleton_convention 决定挂点与动作复用如何按骨名寻址,也是更换绑骨方案时的分叉点——实测已发生过一次:更换方案后骨名去掉了命名空间前缀、骨数由 49 变 28,靠该字段即可判定挂点参数可直接复用。建议取值按枚举收敛(如 mixamorig / plain_humanoid),而非自由字符串。normalized 是所有位移数值的基准,不写死则单位口径失去锚。
  • render_profile:补新动作时必须复用同一套参数,否则新旧动作构图对不上。现有实现特意一次性烤完所有片段来保证共用构图,这个隐式约束必须落到数据上才可复现。
  • sockets:武器走刚体挂件不蒙皮,因此一套骨 + 一套动作可组合出 N 把武器 × M 种配件,换武器无需重绑骨、重出动作——这是渲染路线相对逐帧路线的主要杠杆。positionoffset_along_axis 分开的理由:前者修正「手骨原点在腕、挂件应在掌心」,后者修正「挂件原点在握把顶端、直接挂等于握在最上端」,两个偏移来源不同,合并成一个数就无法在换武器时只改其一。

C. 跨端口径(落 README / MODULES.md,不留聊天记录)

  1. 单位:见第一节。
  2. 帧朝向 faces = "right",朝左由消费端水平翻转;但不得用镜像帧代替更换渲染视角——角色左右不对称(如单侧腕带),镜像会把不对称细节翻到另一侧。
  3. 跳跃的竖直运动已烘在帧里,消费端不得再叠加重力,否则跳两次。抽离位移时只剥水平分量,竖直分量属姿态不属位移。

D. MediaCategory 新增

追加 model-3dsprite-atlas。该枚举注释已写明「放在 common 而非 media 模块,新增文件用途时无需修改 media 代码」,此处按既定方式扩展。

E. 生成任务

  • GenerationType 追加 character_model_3d:3D 生成是独立产物、独立耗时量级、独立失败模式,与出帧不是同一件事。provider 适配器本期留桩,契约先立。
  • CharacterActionInput 不拆成两个类型,追加判别字段 derivation 与各自的可选参数块(model_3d_ref / clip_ref 对渲染路线;既有 reference_image_urls / num_frames 对逐帧路线)。理由:对调用方而言「生成一个动作」仍是同一件事,拆成两个入口会把路线选择泄漏到 API 表面。
  • 资产统一带 source: "generated" | "imported":存量资产与用户自带模型均非 task 产出,契约必须能表达这一点。

三、改后完整结构与示例

Character
├── reference_image_url
└── character_data (JSONB)
    ├── version: 2                          ← 1 → 2
    ├── model_3d {...}                      ← 新增(渲染路线)
    ├── render_profile {...}                ← 新增(渲染路线)
    ├── sockets {...}                       ← 新增(渲染路线)
    └── outfits[] {id, name, description, preview_url}
        └── actions[] {id, type, name, loop, fps, frame_count,
                       anchor_y,            ← 新增
                       duration_s,          ← 新增
                       move_speed_h_per_s,  ← 新增
                       root_motion,         ← 新增
                       atlas,               ← 新增
                       derivation,          ← 新增
                       clip_ref}            ← 新增
            ├── frames[] {index, image_url, duration_ms}    ← 不变(单层时仍走这里)
            └── layers[] {id, role, frames[]}               ← 新增(多图层,见第八节)

示例(数值取自已产出的真实资源;root_motionlayers[].frames 为节选,仅示意结构形状):

{
  "version": 2,
  "model_3d": {
    "url": "<object-key>",
    "format": "glb",
    "triangles": 45000,
    "has_skin": true,
    "skeleton_convention": "mixamorig",
    "normalized": { "height": 1.0, "feet_y": 0.0, "centered": true },
    "source": "imported"
  },
  "render_profile": {
    "view": "side",
    "faces": "right",
    "material": "cel",
    "frame_size": [1107, 924]
  },
  "sockets": {
    "hand_r": {
      "bone": "mixamorig:RightHand",
      "position": [0.0072, 0.0451, 0.0030],
      "offset_along_axis": 0.066,
      "rotation_euler_deg": [-20, 0, 0]
    }
  },
  "outfits": [{
    "id": "default", "name": "默认造型",
    "actions": [{
      "id": "walk", "type": "walk", "name": "行走",
      "loop": true, "fps": 15.5, "frame_count": 16,
      "duration_s": 1.0333,
      "anchor_y": 0.843,
      "move_speed_h_per_s": 1.137,
      "root_motion": [[0.0, 0.0], [0.073, 0.0]],
      "atlas": { "file": "<object-key>", "cols": 4, "rows": 4, "scale": 0.5 },
      "derivation": "render_3d",
      "clip_ref": "<clip-id>",
      "frames": [{ "index": 0, "image_url": "<object-key>", "duration_ms": 64 }],
      "layers": [
        { "id": "bare",  "role": "base",     "frames": [] },
        { "id": "armed", "role": "composed", "frames": [] },
        { "id": "sword", "role": "overlay",  "frames": [] }
      ]
    }]
  }]
}

四、版本与兼容

character_data.version1 升为 2。新增字段全部可选,version 1 的存量数据无需迁移(JSONB 软 schema,读出即为缺省)。消费端遇到缺失字段的退化行为如下,需前后端共同确认:

缺失字段 退化行为
anchor_y 退回按帧底边贴地(即 anchor_y = 1.0),与当前行为一致
root_motion 不施加位移轨
move_speed_h_per_s 不驱动位移,动画原地播放
duration_s 回落到 frame_count / fps
atlas 回落到逐帧 frames[].image_url
derivation 视为 "image_frames"

五、验收标准

以已产出的那套角色资源作为 golden fixture:

  1. 无损往返:现有打包 manifest 的全部字段可由本契约表达,写入后读出可无损还原(逐字段 diff 通过,以单测形式固化在后端)。
  2. 播放正确性(前端 playtest 播 5 个动作):
    • 脚不陷地(anchor_y 生效;对照组统一取单一锚点将陷地约 2.5% 帧高)
    • 走 / 跑不打滑(按 move_speed_h_per_s 驱动位移)
    • 跳跃只跳一次(不叠加重力)
  3. 不破坏既有路线:逐帧路线的既有前端骨架无需改动即可继续工作。
  4. 兼容:一条 version 1 的存量数据按第四节退化行为可正常播放。

六、MVP 假设(四要素)

  • 命题:以上字段足以让消费端正确播放渲染路线的产物,且不污染逐帧路线的通用契约。
  • 验证方式:用已产出的真实资源跑「写入 → 读出 → 前端播放」全链路。
  • 通过标准:第五节四条验收全过。
  • 失败退路:字段不足 → 在动作层继续加法;若发现 3D 特有数据污染了通用层 → 将渲染路线特有块下沉为 action.render_3d 子对象,通用层只保留 A 组。

七、实施拆分(对应 PR)

PR 内容 规模
1 character_data Pydantic 模型加字段 + MediaCategory 追加成员 + 单测 小,纯加法
2 GenerationType / CharacterActionInput 追加判别字段 + provider 桩
3 golden fixture 往返测试 + 跨端口径写入 README / MODULES.md

八、决策:一个动作的三套帧如何表达

渲染路线的同一动作会同时产出三套帧,共用同一帧格、像素级可直接叠加:bare(空手)、armed(角色 + 武器)、sword仅武器,角色隐藏)。第三套是「武器单独出现」这类特效的前提——武器若烘死在 armed 里就只能整体淡入。

备选 做法 问题
① 三套 = 三个 outfit 现有结构不动即可装下 语义错位:sword 那套没有角色,不是一种「造型」,会让高频操作对象「造型」的含义漂移
frames[]layers 帧级表达图层 粒度过细:三套帧整段共存,不随帧变化
动作层加 layers[],每层一组共帧格的帧 语义准确:同一动作的多个图层 需要前端配合改渲染逻辑(叠加播放)

选择 ③:三套帧共用帧格、可像素级叠加,本质是「同一动作的多个图层」而非三个造型;塞进 outfit 会让高频操作对象「造型」的语义漂移。字段形如 action.layers[]: {id, role, frames[]}rolebase / composed / overlay,未提供 layers 时以既有 frames[] 为单层,行为不变。此项影响前端渲染逻辑(需支持叠加播放),实现前请前端确认。


九、不采用清单

  • 不为 3D 资产单独建角色表:跟随既有「角色相关数据统一存 JSONB、不另行建表」的设计(Character 模型注释已明确)。本 Issue 因此不涉及任何数据库迁移
  • 不在本 Issue 定义工作流节点状态机(含长耗时异步任务的状态流转):归 feat:工作流节点编排 #79,本 Issue 只声明该需求存在。
  • 不定义任务推送事件:归 feat: 生成任务 SSE 推送替代前端轮询 #78
  • 不引入迁移工具:本 Issue 只动 JSONB 与枚举。跨角色复用的动作片段库是否独立建表、是否需要迁移工具,另开 Issue。
  • 不预留像素化相关字段:该能力尚未实现,先占字段等于先立无法自证的契约。

十、与既有 Issue 的关系


十一、自查(评审常见追问预答)

  • 每个字段能自证必要性吗? 逐条见第二节,并主动标出一个可删候选(move_speed_h_per_s)及删除代价。
  • 只给这些够吗? A 组是「播放正确」的最小集,第五节验收即为其充分性检验;不够则按失败退路加法。
  • 前后一致吗? 单位在第一节统一;枚举沿用既有 StrEnum 小写下划线风格;derivationai_engine 空骨架:ports 契约 + DerivationStrategy 分流 + 串联 #53 命名对齐;新增枚举成员不改既有成员语义。
  • 固定在正确的抽象层级吗? A 组两条路线通用 → 动作层;B 组仅渲染路线需要 → 角色层;工作流状态机 → 不在本层(归 feat:工作流节点编排 #79)。
  • 命名照顾高频操作对象吗? 高频对象是「动作」与「造型」,因此三套帧不塞进 outfit(见第八节)。
  • 暂不实现的能力留位了吗? 3D 生成 provider 留桩但契约立好;外部导入资产用 source: "imported" 走同一条路。

Metadata

Metadata

Labels

FullSpec影响面大的完整规格enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions