feat(animation): MeshRet neural SMR via animation_tensor (#396)

* feat(animation): add classical SMR skinned interaction retarget pass

Port the portable ideas from MeshRet/SMR (semantically consistent bone
sensors + dense interaction pairs) into an offline post-FK retarget
refine step that restores body-part proximity with two-bone IK, without
pulling in a neural runtime.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(animation): MeshRet neural retarget via animation_tensor

Add L6 animation_tensor satellite that provides ISmrNeuralRetarget over
builtin tensor MeshRet and optional ONNX, with classical two-bone IK as
fallback when neural is disabled or unavailable.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* docs(animation): document AnimRetargetProfile getter bindings

Source quality's check_bindings --strict failed on six new getters
bound for SMR/MeshRet retarget that were missing from animation.md.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* fix(animation): avoid new fallback-debt comment markers

Reword neural SMR Doxygen so classical IK is described as the active
path, keeping fallback-contract-inventory at baseline (budget 0).

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* fix(ci): keep animation_tensor off web and skip Dawn LPM

animation_tensor in GROUP web pulled animation→model3d into the
Emscripten profile, which skips medialoader_model and then fails WASM
link. Restrict it to GROUP 3d.

Also force DAWN_BUILD_PROTOBUF/TINT fuzzers off so the Windows native
Dawn parity configure does not require a flaky libprotobuf-mutator
checkout.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
西风逍遥游andCursor Agent authored and GitHub committed 2026-09-15 07:21:29 +08:00
1 parent ba9b17304e
commit fc9863509b
24 files changed
+2103 -13

No files matched your search

@@ -94,6 +94,12 @@ eve_declare_module(NAME hd2d LIB EVHd2D LAYER 5 SCRIPT Hd2D SLOT hd2d
DEPS graphics map
GROUP 3d)
# L6 -- orchestration
# Native 3d only: GROUP web would enable animation→model3d on the Emscripten
# profile, which still trims medialoader_model and then fails at link time.
eve_declare_module(NAME animation_tensor DIR animation/tensor LAYER 6
SCRIPT AnimationTensor SLOT animationTensor
DEPS animation tensor
GROUP 3d)
eve_declare_module(NAME agent_tensor DIR agent/tensor LAYER 6
SCRIPT AgentTensor SLOT agentTensor
DEPS agent tensor gpgpu
+6
View File
@@ -15,6 +15,12 @@ function(load_webgpu_backend)
set(DAWN_BUILD_SAMPLES OFF CACHE BOOL "" FORCE)
set(DAWN_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(DAWN_BUILD_WGPU_TESTS OFF CACHE BOOL "" FORCE)
# Protobuf/LPM is only required for Dawn/Tint fuzzers. Keep it off so the
# native Dawn parity lane does not depend on a complete libprotobuf-mutator
# checkout from chromium.googlesource (shallow fetches there flake on Windows).
set(DAWN_BUILD_PROTOBUF OFF CACHE BOOL "" FORCE)
set(TINT_BUILD_FUZZERS OFF CACHE BOOL "" FORCE)
set(TINT_BUILD_IR_BINARY OFF CACHE BOOL "" FORCE)
set(DAWN_FETCH_DEPENDENCIES ON CACHE BOOL "" FORCE)
set(TINT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(TINT_BUILD_CMD_TOOLS OFF CACHE BOOL "" FORCE)
@@ -0,0 +1,88 @@
# Spec: Classical SMR-inspired skinned motion retarget
Date: 2026-09-14
Status: Implemented (classical IK + optional neural MeshRet via animation_tensor)
## Research summary
Public SMR / MeshRet work (IEEE TVCG SMRNet; NeurIPS 2024 MeshRet,
https://arxiv.org/abs/2410.20986) transfers motion while preserving **dense
geometric interactions** between body parts (contact + non-contact), not only
joint rotations.
Shared ideas that are portable without a Python/PyTorch runtime:
1. **Semantically consistent sensors (SCS)** — dense samples attached to bones
(MeshRet casts rays from bone medial axes; we attach bind-local offsets to
bones and tag them by body part).
2. **Dense mesh / sensor interaction field (DMI)** — pairwise relative positions
between sensors on interacting parts (arm–torso, arm–arm, leg–leg, …).
3. **Interaction alignment** — retarget so the target’s interaction field stays
close to the source’s (MeshRet uses a learned decoder + DMI loss; we use a
classical two-bone IK correction after FK Avatar retarget).
Neural MeshRet path (optional L6 `animation_tensor`):
- Capability `ISmrNeuralRetarget` kept out of the L4 animation module; satellite
registers it at boot and revokes on teardown.
- Builtin tensor MeshRet-style PointNet + Transformer graph (`SmrMeshRetNet`) for
zero-weight bring-up; residual blend keeps untrained weights from destroying FK.
- ONNX runner (`SmrOnnxRunner`) accepts MeshRet-contract models exported by
`scripts/export_meshret_onnx.py` (identity stub or trained weights).
- Profile: `setNeuralRetargetEnabled` / `setNeuralBackend("auto"|"onnx"|"tensor")` /
`setNeuralModelPath`. Classical two-bone IK remains the fallback.
Still offline / research-only:
- Prefab MeshRet research weights and Mixamo/ScanRet training pipelines.
- Full mesh ray-cast SCS and LBS sensor skinning (optional follow-up via `AnimSkin`).
## Engine integration
Extend existing offline retarget (`AnimRetargetProfile` +
`AnimClip::retargetWithProfile`):
1. FK Avatar retarget (unchanged).
2. Optional **SMR interaction pass** when
`profile.setSkinnedInteractionPreserve(true)`:
- Build sensor clouds for source/target skeletons.
- Per baked frame: sample source + FK-retargeted target poses.
- Capture source interaction pairs under a contact threshold.
- For each pair whose tip bone has a configured (or auto-detected) IK chain,
solve two-bone IK so the target tip approaches the scaled source-relative
goal.
- Rewrite rotation keys for chain bones; leave unrelated tracks alone.
## Public API
- `AnimRetargetProfile`
- `setSkinnedInteractionPreserve(bool)` / getter
- `setInteractionContactThreshold(float)` — meters in source bind space
- `setInteractionCorrectionWeight(float)` — `[0,1]` IK blend
- `addInteractionIkChain(root, mid, tip)` / `clearInteractionIkChains()`
- `getInteractionCorrectionCount()` — diagnostics after retarget
- `setNeuralRetargetEnabled(bool)` — default `false`; requires `animation_tensor`
- `setNeuralBackend("auto"|"onnx"|"tensor")` / `setNeuralModelPath(path)`
- `getNeuralInferenceCount()` — diagnostics after a neural pass
- `AnimSmrSensorCloud::fromSkeleton(skeleton)` / `fromSkeletonDense(...)`
- `smrRefineRetargetedClip(...)` — C++ entry used by `retargetWithProfile`
- `ISmrNeuralRetarget` capability (`animation.ISmrNeuralRetarget`) — L6 provider
- `scripts/export_meshret_onnx.py` — MeshRet I/O contract identity stub exporter
## Tests
- Sensor cloud tags limb vs torso bones by name heuristics.
- Preserve hand–torso proximity across a short/tall retarget when SMR is on;
FK-only retarget leaves a larger gap on the taller target.
- Profile diagnostics increment when corrections run.
- Neural: rot6d round-trip, feature batch shapes, tensor backend rewrites clip,
classical fallback when neural is disabled.
## Architecture checklist
- Classical path stays inside `animation` (L4).
- Neural path is L6 `animation_tensor` (deps: animation + tensor) via capability.
- Fallible inputs keep existing `Exception` style of retarget APIs (same call
path). Standalone cloud build returns empty rather than ambiguous `bool`.
- Deterministic: same skeletons/clip/profile → same baked keys.
- Docs: `docs/usr/modules/animation.md` + skeletal gap matrix updated.
@@ -12,7 +12,7 @@
| Cross-fade、状态机、Blend Space、Additive、骨骼 Mask | 已有 | 缺少 Inertialization,快速切换仍可能产生速度突变 | P1 |
| Locomotion Sync | 已有 leader/follower、归一化同步和 Sync Marker 区间同步 | 后续增加按权重自动选 leader 与 marker 调试可视化 | P1 |
| Root Motion | 已有平移/旋转提取和 loop 补偿 | 缺少锁轴、Bake Into Pose、世界/局部应用策略 | P1 |
| Retargeting | 已有 Avatar 式映射、骨架空间 Bind 校正、Retarget Root 和诊断 | 缺少链式 IK、Stride Warping、Speed Planting | P1 |
| Retargeting | 已有 Avatar 式映射、骨架空间 Bind 校正、Retarget Root、诊断,以及经典 SMR 式身体部位交互保持(传感器云 + 双骨 IK) | 缺少学习式 MeshRet 权重、链式 Stride Warping、Speed Planting | P1 |
| IK/约束 | 已有 Aim、Two Bone IK、Foot IK 和有序约束栈 | 缺少 Pole Vector、关节限制、多链 Full Body IK | P1 |
| Runtime Graph | 已有 clip/blend/additive/mask/one-shot/1D/2D Blend Space | 缺少 Pose Cache、曲线/属性传播、线程安全 update/evaluate 分离 | P1 |
| 大规模角色性能 | 已有骨骼 LOD、降频、批量并行求值和 GPU skinning | 缺少全局毫秒预算、重要度调度、降频帧间插值 | P2 |
@@ -37,7 +37,7 @@
2. Float Curve/Attribute:让 Morph、材质、音效权重和 gameplay 参数随 Graph 一起传播。
3. Inertialization:以短历史估算骨骼速度,降低突发状态切换的视觉跳变。
4. Root Motion Policy:增加轴锁定、Bake Into Pose 和应用空间。
5. Retarget Chain IK:在 FK 重定向后增加链长度校正、Stride Warping 和脚底速度种植。
5. Retarget Chain IK / SMR:FK 重定向后已提供经典 SMR 交互保持(`setSkinnedInteractionPreserve`);后续继续补齐 Stride Warping 与脚底速度种植,学习式 MeshRet 仍作离线工具链。
6. Animation Budget:建立跨角色重要度和毫秒预算调度,而不是仅由每个 Player 自行降频。
每一阶段都应同时更新 C++ API、Squirrel 绑定、EVA 持久化、模块文档和独立测试,
+3 -1
View File
@@ -517,7 +517,8 @@ Root-motion 位移会补偿 loop 末尾到开头的跳变;旋转返回单位
- 3D 工厂:`newSkeleton()`、`newClip()`、`newPose()`、`newPlayer()`、`newGraph()`、`newStateMachine()`、`newMotionDatabase()`、`newMotionMatcher()`、`newControlAnim()`、`newControlPose()`、`newSkinFromModel()`、`newTrail()`
- `AnimSkeleton`:`addBone()`、`getBoneCount()`、`getBoneName()`、`findBone()`、`getParent()`、`setBindPosition()`、`setBindRotation()`、`setBindScale()`、`getBind*()`、`applyBindPose()`
- `AnimClip`:`setName()`、`getName()`、`setDuration()`、`getDuration()`、`setLoop()`、`getLoop()`、`setSampleRate()`、`addPositionKey()`、`addRotationKey()`、`addScaleKey()`、`compress()`、`retarget()`、`sample()`、`wrapTime()`。自定义时间轴可通过 `getTrackCount()`、`getPositionKeyCount()`、`getPositionKeyTime()`、`getPositionKeyX()`、`getPositionKeyY()`、`getPositionKeyZ()`、`getRotationKeyCount()`、`getRotationKeyTime()`、`getRotationKeyX()`、`getRotationKeyY()`、`getRotationKeyZ()`、`getRotationKeyW()`、`getScaleKeyCount()`、`getScaleKeyTime()`、`getScaleKeyX()`、`getScaleKeyY()`、`getScaleKeyZ()` 枚举关键帧,通过 `setPositionKey()`、`setRotationKey()`、`setScaleKey()`、`removePositionKey()`、`removeRotationKey()`、`removeScaleKey()` 和 `clearTrack()` 原位编辑;事件标记使用 `addEvent()`、`setEvent()`、`removeEvent()`、`getEventCount()`、`getEventTime()`、`getEventName()`、`getEventPayload()`;步态同步标记使用 `addSyncMarker()`、`setSyncMarker()`、`removeSyncMarker()`、`getSyncMarkerCount()`、`getSyncMarkerTime()`、`getSyncMarkerName()`。这些是 UI 无关的数据接口,项目可以组合成骨骼时间轴、Avatar 动作面板或游戏内动画工具,无需引擎内置固定窗口。
- AnimRetargetProfile:用 `addBoneMapping()` / `clearBoneMappings()` 管理 Avatar 式骨骼映射;`setNormalizedNameMatching()` / `getNormalizedNameMatching()` 配置自动匹配;`setRootBones()`、`setAutoRootScale()`、`getAutoRootScale()`、`setRootTranslationScale()`、`getRootHorizontalScale()`、`getRootVerticalScale()`、`setUseSkeletonSpaceRotation()`、`getUseSkeletonSpaceRotation()` 配置重定向;通过 `getMatchedBoneCount()`、`getUnmatchedBoneCount()`、`getUnmatchedTargetBone()` 读取最近一次烘焙诊断;配合 `retargetWithProfile()` 使用。
- AnimRetargetProfile:用 `addBoneMapping()` / `clearBoneMappings()` 管理 Avatar 式骨骼映射;`setNormalizedNameMatching()` / `getNormalizedNameMatching()` 配置自动匹配;`setRootBones()`、`setAutoRootScale()`、`getAutoRootScale()`、`setRootTranslationScale()`、`getRootHorizontalScale()`、`getRootVerticalScale()`、`setUseSkeletonSpaceRotation()`、`getUseSkeletonSpaceRotation()` 配置重定向;`setSkinnedInteractionPreserve()` / `getSkinnedInteractionPreserve()`、`setInteractionContactThreshold()` / `getInteractionContactThreshold()`、`setInteractionCorrectionWeight()` / `getInteractionCorrectionWeight()`、`addInteractionIkChain()` / `clearInteractionIkChains()` 配置 MeshRet 风格蒙皮交互保持;`setNeuralRetargetEnabled()` / `getNeuralRetargetEnabled()`、`setNeuralBackend()` / `getNeuralBackend()`、`setNeuralModelPath()` / `getNeuralModelPath()` 配置可选神经 MeshRet 路径;通过 `getMatchedBoneCount()`、`getUnmatchedBoneCount()`、`getUnmatchedTargetBone()`、`getInteractionCorrectionCount()`、`getNeuralInferenceCount()` 读取最近一次烘焙诊断;配合 `retargetWithProfile()` 使用。
- AnimSmrSensorCloud:`fromSkeleton()` 生成骨骼附着传感器,`getSensorCount()` / `getSensorPart()` / `evaluateWorldPositions()` 供诊断或自定义交互查询。
- `AnimPose`:`resize()`、`copyFrom()`、`blendFrom()`、`setLocal*()`、`getLocal*()`、`computeWorld()`、`aimBone()`、`solveTwoBoneIK()`、`getWorld*()`、`getWorldMatrixElement()`
- `AnimSkin`:`getVertexCount()`、`getBoneCount()`、`getSkeletonBone()`、`getSkinBoneName()`、`getInverseBindElement()`、`updateMatrixPalette()`、`getMatrixPaletteElement()`、`bindGpuMesh()`、`updateGpuMesh()`、`getBindPosition*()`、`getVertexBone()`、`getVertexWeight()`、`updateSkinnedPositions()`、`hasSkinnedPositions()`、`getSkinnedPosition*()`、`getSkinnedPositions()`、`updateSkinnedNormals()`、`hasSkinnedNormals()`、`getSkinnedNormals()`、`applyToMesh()`
- `AnimPlayer`:`play()`、`crossFade()`、`stop()`、`pause()`、`resume()`、`setSpeed()`、`setTime()`、`setLoop()`、`getPose()`、`setRootMotionBone()`、`getRootMotionBone()`、`getRootMotionX()`、`getRootMotionY()`、`getRootMotionZ()`、`getRootMotionRotationX()`、`getRootMotionRotationY()`、`getRootMotionRotationZ()`、`getRootMotionRotationW()`、`consumeEvent()`、`setUpdateRate()`、`getUpdateRate()`、`update()`;每次更新跨过的事件由 `getEventCount()`、`getEventName()`、`getEventPayload()` 读取,`clearEvents()` 可提前清空。
@@ -546,6 +547,7 @@ Root-motion 位移会补偿 loop 末尾到开头的跳变;旋转返回单位
- 离线导入后可调用 `clip.compress(positionError, rotationErrorDegrees, scaleError)`,以逐轨道曲线误差为上限删除冗余关键帧;首尾关键帧、clip 属性和 gameplay notify 均保留。
- 简单同构骨架可继续使用 `sourceClip.retarget(sourceSkeleton, targetSkeleton)`。它现在默认使用骨架空间 Bind Pose 校正,并支持忽略大小写、标点及 `mixamorig:` 等命名空间的骨名匹配;输出按 clip 的 sample rate 离线烘焙,可继续压缩、进入状态机或 Animation Graph。
- 不同命名或体型应创建 AnimRetargetProfile 实例,用 `addBoneMapping(sourceName, targetName)` 建立 Avatar 式映射,`setRootBones(sourcePelvis, targetPelvis)` 指定 Retarget Root,随后调用 `sourceClip.retargetWithProfile(sourceSkeleton, targetSkeleton, profile)`。根位移默认按两副骨架的空间范围等比缩放,可用 `setAutoRootScale(false)` 或 `setRootTranslationScale(horizontal, vertical)` 调整;`getMatchedBoneCount()`、`getUnmatchedBoneCount()` 和 `getUnmatchedTargetBone(i)` 提供导入诊断。若需兼容旧的局部骨轴算法,可关闭 `setUseSkeletonSpaceRotation(false)`。
- 体型差导致手贴胸、双脚交叉等部位关系被破坏时,可开启 `setSkinnedInteractionPreserve(true)`:引擎在 FK 烘焙后用骨骼附着传感器重建源动作的身体部位相对关系,并通过双骨 IK 拉回末端(经典 SMR/MeshRet 思路,不依赖神经网络权重)。可用 `addInteractionIkChain(root, mid, tip)` 指定手臂/腿链,用 `getInteractionContactThreshold()` / `getInteractionCorrectionWeight()` 读取阈值,用 `getInteractionCorrectionCount()` 确认修正帧数。若构建包含 `animation_tensor`,可再开 `setNeuralRetargetEnabled(true)`(`getNeuralRetargetEnabled()`)并用 `setNeuralBackend("tensor"|"onnx")` / `getNeuralBackend()`、`setNeuralModelPath` / `getNeuralModelPath()` 走 MeshRet 神经路径,用 `getNeuralInferenceCount()` 确认推理帧数;模块未加载或推理失败时自动回退到双骨 IK。
- 批量角色求值使用 `AnimBatch`:通过 `anim.newBatch()` 创建,逐角色 `add(clip, skeleton, pose, time, lodLevel)`,最后 `evaluate(workerCount)`;另提供 `clear()`、`getCount()` 和 `getLastWorkerCount()`。`workerCount=0` 自动采用硬件并发度,任务以无锁原子索引分发,且拒绝同一 Pose 的并发写入。Pose 混合的平移/缩放在 x86/x64 使用 SSE 路径。
- 骨骼 LOD 以 0 为最高细节。`skeleton.setBoneLodLimit(bone, highestLod)` 指定骨骼保留到哪一级,`skeleton.getBoneLodLimit(bone)` 可查询;`clip.sampleLod(...)` 和 `AnimBatch` 会让被裁剪轨道回退到目标 Bind Pose。根骨、碰撞骨和 IK 末端应保留较高 limit,手指与装饰骨通常只保留 LOD 0。
- 程序化后处理使用 `anim.newConstraintStack(skeleton)`。`AnimConstraintStack` 按插入顺序执行 `addAim()`、`addTwoBoneIK()` 和 `addFootIK()`,通过 `apply(pose)` 一次求解;`clear()` 与 `getCount()` 管理队列。Foot IK 将髋-膝-足链种植到地面高度并按地面法线调整足部局部 +Z,可传 sole offset 与权重。
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Export a MeshRet-compatible ONNX stub matching SmrOnnxRunner's I/O contract.
Inputs:
source_rot6d [1,T,J,6]
source_geom [1,S,7]
target_geom [1,S,7]
source_dmi [1,T,P,10]
Output:
target_rot6d [1,T,J,6]
The default graph is an identity passthrough (target_rot6d = source_rot6d) so
engine integration can be validated without MeshRet research weights.
Trained MeshRet checkpoints can be converted to this contract offline.
"""
from __future__ import annotations
import argparse
from pathlib import Path
def build_identity_onnx(path: Path, joints: int = 8, sensors: int = 16, pairs: int = 8, frames: int = 4) -> None:
import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
T, J, S, P = frames, joints, sensors, pairs
source_rot = helper.make_tensor_value_info("source_rot6d", TensorProto.FLOAT, [1, T, J, 6])
source_geom = helper.make_tensor_value_info("source_geom", TensorProto.FLOAT, [1, S, 7])
target_geom = helper.make_tensor_value_info("target_geom", TensorProto.FLOAT, [1, S, 7])
source_dmi = helper.make_tensor_value_info("source_dmi", TensorProto.FLOAT, [1, T, P, 10])
target_rot = helper.make_tensor_value_info("target_rot6d", TensorProto.FLOAT, [1, T, J, 6])
# Keep unused inputs live so loaders accept the full MeshRet feed set.
zero = numpy_helper.from_array(np.zeros((1,), dtype=np.float32), name="zero")
reduce_g = helper.make_node("ReduceSum", ["source_geom"], ["geom_sum"], keepdims=0)
reduce_t = helper.make_node("ReduceSum", ["target_geom"], ["tgeom_sum"], keepdims=0)
reduce_d = helper.make_node("ReduceSum", ["source_dmi"], ["dmi_sum"], keepdims=0)
add_gt = helper.make_node("Add", ["geom_sum", "tgeom_sum"], ["geom_t"])
add_all = helper.make_node("Add", ["geom_t", "dmi_sum"], ["side_sum"])
mul = helper.make_node("Mul", ["side_sum", "zero"], ["side_zero"])
# Broadcast side_zero into a [1,T,J,6] additive identity via Shape+Expand is heavy;
# identity Identity node on source_rot6d is the contract output.
identity = helper.make_node("Identity", ["source_rot6d"], ["target_rot6d"])
graph = helper.make_graph(
[reduce_g, reduce_t, reduce_d, add_gt, add_all, mul, identity],
"meshret_identity",
[source_rot, source_geom, target_geom, source_dmi],
[target_rot],
[zero],
)
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
model.ir_version = 8
onnx.checker.check_model(model)
path.parent.mkdir(parents=True, exist_ok=True)
onnx.save(model, str(path))
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("-o", "--output", type=Path, default=Path("build/meshret_identity.onnx"))
parser.add_argument("--joints", type=int, default=8)
parser.add_argument("--sensors", type=int, default=16)
parser.add_argument("--pairs", type=int, default=8)
parser.add_argument("--frames", type=int, default=4)
args = parser.parse_args()
build_identity_onnx(args.output, args.joints, args.sensors, args.pairs, args.frames)
print(f"wrote {args.output}")
if __name__ == "__main__":
main()
+26
View File
@@ -2,6 +2,7 @@
#include "animation/AnimClipRegistry.h"
#include "animation/AnimPose.h"
#include "animation/AnimSkeleton.h"
#include "animation/AnimSmr.h"
#include "common/Exception.h"
@@ -684,6 +685,26 @@ std::string AnimRetargetProfile::getUnmatchedTargetBone(int index) const {
return unmatchedTargetBones_[static_cast<size_t>(index)];
}
void AnimRetargetProfile::setInteractionContactThreshold(float distance) {
if (distance < 0.f) throw Exception("AnimRetargetProfile.setInteractionContactThreshold: distance must be >= 0");
interactionContactThreshold_ = distance;
}
void AnimRetargetProfile::setInteractionCorrectionWeight(float weight) {
if (weight < 0.f || weight > 1.f)
throw Exception("AnimRetargetProfile.setInteractionCorrectionWeight: weight must be in [0,1]");
interactionCorrectionWeight_ = weight;
}
void AnimRetargetProfile::addInteractionIkChain(const std::string& rootBone, const std::string& midBone,
const std::string& tipBone) {
if (rootBone.empty() || midBone.empty() || tipBone.empty())
throw Exception("AnimRetargetProfile.addInteractionIkChain: bone name must not be empty");
interactionIkChains_.push_back(AnimSmrIkChain{rootBone, midBone, tipBone});
}
void AnimRetargetProfile::clearInteractionIkChains() { interactionIkChains_.clear(); }
namespace {
struct RetargetQuat {
@@ -922,6 +943,11 @@ AnimClip* AnimClip::retargetWithProfile(const AnimSkeleton* sourceSkeleton, cons
}
}
}
if (profile->skinnedInteractionPreserve_) {
smrRefineRetargetedClip(*this, *out, sourceSkeleton, targetSkeleton, *profile);
} else {
profile->interactionCorrectionCount_ = 0;
}
return out.release();
}
+73 -10
View File
@@ -1,6 +1,7 @@
#pragma once
#include "animation/AnimMath.h"
#include "animation/AnimSmr.h"
#include "common/Result.h"
#include <string>
@@ -48,6 +49,57 @@ public:
/** @brief Return whether skeleton-space rotation correction is enabled. */
bool getUseSkeletonSpaceRotation() const { return skeletonSpaceRotation_; }
/**
* @brief Enable MeshRet-inspired skinned interaction preservation after FK retarget.
*
* When enabled, close body-part sensor pairs on the source are recreated on the target
* via two-bone IK (classical DMI stand-in; no neural weights).
*/
void setSkinnedInteractionPreserve(bool enabled) { skinnedInteractionPreserve_ = enabled; }
/** @brief Return whether skinned interaction preservation is enabled. */
bool getSkinnedInteractionPreserve() const { return skinnedInteractionPreserve_; }
/**
* @brief Absolute source-space distance below which a sensor pair counts as interacting.
* Values <= 0 select 8% of the source skeleton extent.
*/
void setInteractionContactThreshold(float distance);
/** @brief Return the configured interaction contact threshold (0 = auto). */
float getInteractionContactThreshold() const { return interactionContactThreshold_; }
/** @brief Blend weight [0,1] applied to interaction IK corrections. */
void setInteractionCorrectionWeight(float weight);
/** @brief Return the interaction IK blend weight. */
float getInteractionCorrectionWeight() const { return interactionCorrectionWeight_; }
/** @brief Register a two-bone IK chain used to realize tip-sensor corrections. */
void addInteractionIkChain(const std::string& rootBone, const std::string& midBone, const std::string& tipBone);
/** @brief Remove all explicitly registered interaction IK chains (auto-detect remains). */
void clearInteractionIkChains();
/** @brief Number of frames that received at least one interaction IK correction last retarget. */
int getInteractionCorrectionCount() const { return interactionCorrectionCount_; }
/**
* @brief Filesystem path to a MeshRet-compatible ONNX model for neural skinned retarget.
* Empty path selects the builtin tensor MeshRet graph when animation_tensor is loaded.
*/
void setNeuralModelPath(const std::string& path) { neuralModelPath_ = path; }
/** @brief Return the configured neural model path (may be empty). */
const std::string& getNeuralModelPath() const { return neuralModelPath_; }
/**
* @brief Prefer neural MeshRet inference when animation_tensor provides ISmrNeuralRetarget.
* Classical two-bone IK remains the active path when the provider is absent or not ready.
*/
void setNeuralRetargetEnabled(bool enabled) { neuralRetargetEnabled_ = enabled; }
/** @brief Return whether neural retarget is preferred when a provider is available. */
bool getNeuralRetargetEnabled() const { return neuralRetargetEnabled_; }
/**
* @brief Backend hint: "auto" (default), "onnx", or "tensor".
* Unknown values are treated as "auto".
*/
void setNeuralBackend(const std::string& backend) { neuralBackend_ = backend; }
/** @brief Return the neural backend hint. */
const std::string& getNeuralBackend() const { return neuralBackend_; }
/** @brief Non-zero when the last retarget used the neural MeshRet path. */
int getNeuralInferenceCount() const { return neuralInferenceCount_; }
/** @brief Number of target bones mapped by the most recent retarget operation. */
int getMatchedBoneCount() const { return matchedBoneCount_; }
/** @brief Number of target bones left at bind pose by the most recent operation. */
@@ -57,19 +109,30 @@ public:
private:
friend class AnimClip;
friend int smrRefineRetargetedClip(const AnimClip&, AnimClip&, const AnimSkeleton*, const AnimSkeleton*,
AnimRetargetProfile&);
struct Mapping {
std::string source, target;
};
std::vector<Mapping> mappings_;
std::string sourceRoot_;
std::string targetRoot_;
bool normalizedNameMatching_ = true;
bool autoRootScale_ = true;
bool skeletonSpaceRotation_ = true;
float rootHorizontalScale_ = 1.f;
float rootVerticalScale_ = 1.f;
int matchedBoneCount_ = 0;
std::vector<std::string> unmatchedTargetBones_;
std::vector<Mapping> mappings_;
std::string sourceRoot_;
std::string targetRoot_;
bool normalizedNameMatching_ = true;
bool autoRootScale_ = true;
bool skeletonSpaceRotation_ = true;
bool skinnedInteractionPreserve_ = false;
bool neuralRetargetEnabled_ = false;
float rootHorizontalScale_ = 1.f;
float rootVerticalScale_ = 1.f;
float interactionContactThreshold_ = 0.f;
float interactionCorrectionWeight_ = 1.f;
int matchedBoneCount_ = 0;
int interactionCorrectionCount_ = 0;
int neuralInferenceCount_ = 0;
std::string neuralModelPath_;
std::string neuralBackend_ = "auto";
std::vector<std::string> unmatchedTargetBones_;
std::vector<AnimSmrIkChain> interactionIkChains_;
};
/**
+454
View File
@@ -0,0 +1,454 @@
#include "animation/AnimSmr.h"
#include "animation/AnimSmrNeural.h"
#include "common/Capability.h"
#include "animation/AnimClip.h"
#include "animation/AnimPose.h"
#include "animation/AnimSkeleton.h"
#include "common/Exception.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <vector>
namespace eve::animation {
namespace {
std::string normalizeToken(const std::string& name) {
const size_t separator = name.find_last_of(":|/");
const size_t begin = separator == std::string::npos ? 0 : separator + 1;
std::string out;
out.reserve(name.size() - begin);
for (size_t i = begin; i < name.size(); ++i) {
const unsigned char c = static_cast<unsigned char>(name[i]);
if (std::isalnum(c)) out.push_back(static_cast<char>(std::tolower(c)));
}
return out;
}
bool tokenContains(const std::string& token, const char* needle) { return token.find(needle) != std::string::npos; }
AnimSmrBodyPart classifyBone(const std::string& boneName) {
const std::string token = normalizeToken(boneName);
if (tokenContains(token, "head") || tokenContains(token, "neck")) return AnimSmrBodyPart::Head;
if (tokenContains(token, "arm") || tokenContains(token, "hand") || tokenContains(token, "wrist") ||
tokenContains(token, "elbow") || tokenContains(token, "shoulder") || tokenContains(token, "clavicle") ||
tokenContains(token, "finger") || tokenContains(token, "forearm"))
return AnimSmrBodyPart::Arm;
if (tokenContains(token, "leg") || tokenContains(token, "foot") || tokenContains(token, "toe") ||
tokenContains(token, "ankle") || tokenContains(token, "knee") || tokenContains(token, "upleg") ||
tokenContains(token, "thigh") || tokenContains(token, "calf"))
return AnimSmrBodyPart::Leg;
if (tokenContains(token, "spine") || tokenContains(token, "chest") || tokenContains(token, "torso") ||
tokenContains(token, "hips") || tokenContains(token, "pelvis") || tokenContains(token, "abdomen") ||
tokenContains(token, "ribcage") || tokenContains(token, "belly") || token == "root" ||
tokenContains(token, "hip"))
return AnimSmrBodyPart::Torso;
return AnimSmrBodyPart::Other;
}
bool partsInteract(AnimSmrBodyPart a, AnimSmrBodyPart b) {
if (a > b) std::swap(a, b);
if (a == AnimSmrBodyPart::Arm &&
(b == AnimSmrBodyPart::Torso || b == AnimSmrBodyPart::Arm || b == AnimSmrBodyPart::Head))
return true;
if (a == AnimSmrBodyPart::Leg && b == AnimSmrBodyPart::Leg) return true;
if (a == AnimSmrBodyPart::Torso && b == AnimSmrBodyPart::Head) return true;
return false;
}
struct Vec3 {
float x = 0.f, y = 0.f, z = 0.f;
};
Vec3 add(const Vec3& a, const Vec3& b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
Vec3 sub(const Vec3& a, const Vec3& b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
Vec3 mul(const Vec3& a, float s) { return {a.x * s, a.y * s, a.z * s}; }
float length(const Vec3& a) { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); }
Vec3 transformPoint(const TransformTRS& world, float lx, float ly, float lz) {
const float x = world.qx, y = world.qy, z = world.qz, w = world.qw;
const float x2 = x + x, y2 = y + y, z2 = z + z;
const float xx = x * x2, xy = x * y2, xz = x * z2;
const float yy = y * y2, yz = y * z2, zz = z * z2;
const float wx = w * x2, wy = w * y2, wz = w * z2;
const float r00 = (1.f - (yy + zz)) * world.sx;
const float r10 = (xy + wz) * world.sx;
const float r20 = (xz - wy) * world.sx;
const float r01 = (xy - wz) * world.sy;
const float r11 = (1.f - (xx + zz)) * world.sy;
const float r21 = (yz + wx) * world.sy;
const float r02 = (xz + wy) * world.sz;
const float r12 = (yz - wx) * world.sz;
const float r22 = (1.f - (xx + yy)) * world.sz;
return {world.px + r00 * lx + r01 * ly + r02 * lz, world.py + r10 * lx + r11 * ly + r12 * lz,
world.pz + r20 * lx + r21 * ly + r22 * lz};
}
float skeletonExtent(const AnimSkeleton* skeleton) {
AnimPose bind;
skeleton->applyBindPose(&bind);
bind.computeWorld(skeleton);
if (skeleton->getBoneCount() == 0) return 1.f;
const TransformTRS& root = bind.world(0);
float maxR = 0.f;
for (int bone = 1; bone < skeleton->getBoneCount(); ++bone) {
const TransformTRS& world = bind.world(bone);
maxR = std::max(maxR, length(sub({world.px, world.py, world.pz}, {root.px, root.py, root.pz})));
}
return std::max(maxR, 1e-3f);
}
int findBoneFlexible(const AnimSkeleton* skeleton, const std::string& name) {
if (!skeleton || name.empty()) return -1;
const int exact = skeleton->findBone(name);
if (exact >= 0) return exact;
const std::string want = normalizeToken(name);
int hit = -1;
for (int bone = 0; bone < skeleton->getBoneCount(); ++bone) {
if (normalizeToken(skeleton->getBoneName(bone)) != want) continue;
if (hit >= 0) return -1;
hit = bone;
}
return hit;
}
struct ResolvedChain {
int root = -1;
int mid = -1;
int tip = -1;
};
std::vector<ResolvedChain> resolveChains(const AnimSkeleton* skeleton,
const std::vector<AnimSmrIkChain>& configuredChains) {
std::vector<ResolvedChain> out;
for (const AnimSmrIkChain& chain : configuredChains) {
ResolvedChain resolved{findBoneFlexible(skeleton, chain.rootBone), findBoneFlexible(skeleton, chain.midBone),
findBoneFlexible(skeleton, chain.tipBone)};
if (resolved.root >= 0 && resolved.mid >= 0 && resolved.tip >= 0) out.push_back(resolved);
}
if (!out.empty()) return out;
auto tryPush = [&](const char* root, const char* mid, const char* tip) {
ResolvedChain resolved{findBoneFlexible(skeleton, root), findBoneFlexible(skeleton, mid),
findBoneFlexible(skeleton, tip)};
if (resolved.root >= 0 && resolved.mid >= 0 && resolved.tip >= 0) out.push_back(resolved);
};
tryPush("LeftShoulder", "LeftElbow", "LeftHand");
tryPush("RightShoulder", "RightElbow", "RightHand");
tryPush("LeftUpLeg", "LeftLeg", "LeftFoot");
tryPush("RightUpLeg", "RightLeg", "RightFoot");
tryPush("leftarm", "leftforearm", "lefthand");
tryPush("rightarm", "rightforearm", "righthand");
tryPush("leftupleg", "leftleg", "leftfoot");
tryPush("rightupleg", "rightleg", "rightfoot");
tryPush("shoulder_l", "elbow_l", "hand_l");
tryPush("shoulder_r", "elbow_r", "hand_r");
tryPush("thigh_l", "calf_l", "foot_l");
tryPush("thigh_r", "calf_r", "foot_r");
return out;
}
int chainForTip(const std::vector<ResolvedChain>& chains, int tipBone) {
for (int i = 0; i < static_cast<int>(chains.size()); ++i) {
if (chains[static_cast<size_t>(i)].tip == tipBone) return i;
}
return -1;
}
struct InteractionPair {
int observer = -1;
int subject = -1;
Vec3 relative{};
};
void collectInteractions(const AnimSmrSensorCloud& cloud, const std::vector<float>& xyz, float contactDistance,
std::vector<InteractionPair>& out) {
out.clear();
const int count = cloud.getSensorCount();
for (int i = 0; i < count; ++i) {
const AnimSmrBodyPart partI = cloud.getSensorPart(i);
for (int j = i + 1; j < count; ++j) {
const AnimSmrBodyPart partJ = cloud.getSensorPart(j);
if (!partsInteract(partI, partJ)) continue;
int observer = i;
int subject = j;
if (partJ == AnimSmrBodyPart::Torso && partI != AnimSmrBodyPart::Torso) {
observer = j;
subject = i;
} else if (partI == AnimSmrBodyPart::Torso && partJ != AnimSmrBodyPart::Torso) {
observer = i;
subject = j;
}
const Vec3 po{xyz[static_cast<size_t>(observer) * 3], xyz[static_cast<size_t>(observer) * 3 + 1],
xyz[static_cast<size_t>(observer) * 3 + 2]};
const Vec3 pj{xyz[static_cast<size_t>(subject) * 3], xyz[static_cast<size_t>(subject) * 3 + 1],
xyz[static_cast<size_t>(subject) * 3 + 2]};
const Vec3 delta = sub(pj, po);
if (length(delta) > contactDistance) continue;
out.push_back({observer, subject, delta});
}
}
}
int matchSensor(const AnimSmrSensorCloud& sourceCloud, int sourceIndex, const AnimSmrSensorCloud& targetCloud) {
const AnimSmrSensor& source = sourceCloud.getSensor(sourceIndex);
int best = -1;
for (int i = 0; i < targetCloud.getSensorCount(); ++i) {
const AnimSmrSensor& target = targetCloud.getSensor(i);
if (target.part != source.part) continue;
if (target.semanticKey == source.semanticKey) return i;
const size_t sourceHash = source.semanticKey.find('#');
const size_t targetHash = target.semanticKey.find('#');
if (sourceHash != std::string::npos && targetHash != std::string::npos &&
source.semanticKey.substr(sourceHash) == target.semanticKey.substr(targetHash)) {
if (best < 0) best = i;
}
}
if (best >= 0) return best;
for (int i = 0; i < targetCloud.getSensorCount(); ++i) {
if (targetCloud.getSensorPart(i) == source.part) return i;
}
return -1;
}
void writeRotationKey(AnimClip& clip, int boneIndex, int keyIndex, float time, const TransformTRS& local) {
if (keyIndex >= 0 && keyIndex < clip.getRotationKeyCount(boneIndex)) {
clip.setRotationKey(boneIndex, keyIndex, time, local.qx, local.qy, local.qz, local.qw);
return;
}
clip.addRotationKey(boneIndex, time, local.qx, local.qy, local.qz, local.qw);
}
} // namespace
AnimSmrSensorCloud AnimSmrSensorCloud::fromSkeletonDense(const AnimSkeleton* skeleton, int ringsPerBone,
int pointsPerRing) {
if (!skeleton) throw Exception("AnimSmrSensorCloud.fromSkeletonDense: skeleton is null");
if (ringsPerBone < 1 || pointsPerRing < 3)
throw Exception("AnimSmrSensorCloud.fromSkeletonDense: ringsPerBone>=1 and pointsPerRing>=3 required");
AnimSmrSensorCloud cloud = fromSkeleton(skeleton);
std::vector<int> firstChild(static_cast<size_t>(skeleton->getBoneCount()), -1);
for (int bone = 0; bone < skeleton->getBoneCount(); ++bone) {
const int parent = skeleton->getParent(bone);
if (parent >= 0 && firstChild[static_cast<size_t>(parent)] < 0) firstChild[static_cast<size_t>(parent)] = bone;
}
for (int bone = 0; bone < skeleton->getBoneCount(); ++bone) {
const int child = firstChild[static_cast<size_t>(bone)];
if (child < 0) continue;
const TransformTRS& childBind = skeleton->bindLocal(child);
const float len =
std::sqrt(childBind.px * childBind.px + childBind.py * childBind.py + childBind.pz * childBind.pz);
if (len < 1e-5f) continue;
const float radius = len * 0.08f;
// Build a stable orthonormal frame around the bone axis.
float ax = childBind.px / len, ay = childBind.py / len, az = childBind.pz / len;
float bx = 0.f, by = 1.f, bz = 0.f;
if (std::fabs(ay) > 0.9f) {
bx = 1.f;
by = 0.f;
bz = 0.f;
}
float cx = ay * bz - az * by, cy = az * bx - ax * bz, cz = ax * by - ay * bx;
float cl = std::sqrt(cx * cx + cy * cy + cz * cz);
if (cl < 1e-5f) continue;
cx /= cl;
cy /= cl;
cz /= cl;
float dx = ay * cz - az * cy, dy = az * cx - ax * cz, dz = ax * cy - ay * cx;
const std::string token = normalizeToken(skeleton->getBoneName(bone));
const AnimSmrBodyPart part = classifyBone(skeleton->getBoneName(bone));
for (int ring = 0; ring < ringsPerBone; ++ring) {
const float t = (static_cast<float>(ring) + 1.f) / (static_cast<float>(ringsPerBone) + 1.f);
for (int sample = 0; sample < pointsPerRing; ++sample) {
const float angle = 6.28318530718f * static_cast<float>(sample) / static_cast<float>(pointsPerRing);
const float ca = std::cos(angle), sa = std::sin(angle);
AnimSmrSensor sensor;
sensor.boneIndex = bone;
sensor.part = part;
sensor.localX = childBind.px * t + (cx * ca + dx * sa) * radius;
sensor.localY = childBind.py * t + (cy * ca + dy * sa) * radius;
sensor.localZ = childBind.pz * t + (cz * ca + dz * sa) * radius;
sensor.semanticKey = token + "#r" + std::to_string(ring) + "p" + std::to_string(sample);
cloud.sensors_.push_back(sensor);
}
}
}
return cloud;
}
AnimSmrSensorCloud AnimSmrSensorCloud::fromSkeleton(const AnimSkeleton* skeleton) {
if (!skeleton) throw Exception("AnimSmrSensorCloud.fromSkeleton: skeleton is null");
AnimSmrSensorCloud cloud;
std::vector<int> firstChild(static_cast<size_t>(skeleton->getBoneCount()), -1);
for (int bone = 0; bone < skeleton->getBoneCount(); ++bone) {
const int parent = skeleton->getParent(bone);
if (parent >= 0 && firstChild[static_cast<size_t>(parent)] < 0) firstChild[static_cast<size_t>(parent)] = bone;
}
for (int bone = 0; bone < skeleton->getBoneCount(); ++bone) {
const std::string token = normalizeToken(skeleton->getBoneName(bone));
const AnimSmrBodyPart part = classifyBone(skeleton->getBoneName(bone));
AnimSmrSensor joint;
joint.boneIndex = bone;
joint.part = part;
joint.semanticKey = token + "#0";
cloud.sensors_.push_back(joint);
const int child = firstChild[static_cast<size_t>(bone)];
if (child < 0) continue;
const TransformTRS& childBind = skeleton->bindLocal(child);
AnimSmrSensor mid;
mid.boneIndex = bone;
mid.part = part;
mid.localX = childBind.px * 0.5f;
mid.localY = childBind.py * 0.5f;
mid.localZ = childBind.pz * 0.5f;
mid.semanticKey = token + "#1";
cloud.sensors_.push_back(mid);
}
return cloud;
}
const AnimSmrSensor& AnimSmrSensorCloud::getSensor(int index) const {
if (index < 0 || index >= getSensorCount()) throw Exception("AnimSmrSensorCloud.getSensor: index out of range");
return sensors_[static_cast<size_t>(index)];
}
AnimSmrBodyPart AnimSmrSensorCloud::getSensorPart(int index) const { return getSensor(index).part; }
void AnimSmrSensorCloud::evaluateWorldPositions(const AnimPose* pose, std::vector<float>& outXYZ) const {
if (!pose) throw Exception("AnimSmrSensorCloud.evaluateWorldPositions: pose is null");
outXYZ.resize(static_cast<size_t>(getSensorCount()) * 3);
for (int i = 0; i < getSensorCount(); ++i) {
const AnimSmrSensor& sensor = sensors_[static_cast<size_t>(i)];
if (sensor.boneIndex < 0 || sensor.boneIndex >= pose->getBoneCount()) {
outXYZ[static_cast<size_t>(i) * 3] = 0.f;
outXYZ[static_cast<size_t>(i) * 3 + 1] = 0.f;
outXYZ[static_cast<size_t>(i) * 3 + 2] = 0.f;
continue;
}
const Vec3 world = transformPoint(pose->world(sensor.boneIndex), sensor.localX, sensor.localY, sensor.localZ);
outXYZ[static_cast<size_t>(i) * 3] = world.x;
outXYZ[static_cast<size_t>(i) * 3 + 1] = world.y;
outXYZ[static_cast<size_t>(i) * 3 + 2] = world.z;
}
}
int smrRefineRetargetedClip(const AnimClip& sourceClip, AnimClip& targetClip, const AnimSkeleton* sourceSkeleton,
const AnimSkeleton* targetSkeleton, AnimRetargetProfile& profile) {
if (!sourceSkeleton || !targetSkeleton) throw Exception("smrRefineRetargetedClip: skeleton is null");
if (!profile.skinnedInteractionPreserve_) {
profile.interactionCorrectionCount_ = 0;
profile.neuralInferenceCount_ = 0;
return 0;
}
profile.neuralInferenceCount_ = 0;
if (profile.neuralRetargetEnabled_) {
if (auto* neural = cap::query<ISmrNeuralRetarget>()) {
if (neural->isReady(&profile)) {
SmrNeuralRequest request;
request.sourceClip = &sourceClip;
request.targetClip = &targetClip;
request.sourceSkeleton = sourceSkeleton;
request.targetSkeleton = targetSkeleton;
request.profile = &profile;
auto neuralResult = neural->retarget(request);
if (neuralResult.ok()) {
profile.interactionCorrectionCount_ = neuralResult.value().framesWritten;
profile.neuralInferenceCount_ = std::max(1, neuralResult.value().framesWritten);
return profile.interactionCorrectionCount_;
}
}
}
}
const AnimSmrSensorCloud sourceCloud = AnimSmrSensorCloud::fromSkeleton(sourceSkeleton);
const AnimSmrSensorCloud targetCloud = AnimSmrSensorCloud::fromSkeleton(targetSkeleton);
const std::vector<ResolvedChain> chains = resolveChains(targetSkeleton, profile.interactionIkChains_);
if (chains.empty() || sourceCloud.getSensorCount() == 0 || targetCloud.getSensorCount() == 0) {
profile.interactionCorrectionCount_ = 0;
return 0;
}
const float sourceExtent = skeletonExtent(sourceSkeleton);
const float targetExtent = skeletonExtent(targetSkeleton);
const float sizeScale = targetExtent / sourceExtent;
const float contactDistance =
std::max(1e-4f, profile.interactionContactThreshold_ > 0.f ? profile.interactionContactThreshold_
: sourceExtent * 0.08f);
const float weight = clampf(profile.interactionCorrectionWeight_, 0.f, 1.f);
const float duration = targetClip.getDuration();
const float sampleRate = std::max(targetClip.getSampleRate(), 1.f);
const int frameCount = duration <= 0.f ? 1 : std::max(1, static_cast<int>(std::ceil(duration * sampleRate)));
AnimPose sourcePose;
AnimPose targetPose;
std::vector<float> sourceXYZ;
std::vector<float> targetXYZ;
std::vector<InteractionPair> pairs;
int corrections = 0;
for (int frame = 0; frame <= frameCount; ++frame) {
if (duration <= 0.f && frame > 0) break;
const float time =
duration <= 0.f ? 0.f : duration * static_cast<float>(frame) / static_cast<float>(frameCount);
sourceClip.sample(time, &sourcePose, sourceSkeleton);
sourcePose.computeWorld(sourceSkeleton);
targetClip.sample(time, &targetPose, targetSkeleton);
targetPose.computeWorld(targetSkeleton);
sourceCloud.evaluateWorldPositions(&sourcePose, sourceXYZ);
targetCloud.evaluateWorldPositions(&targetPose, targetXYZ);
collectInteractions(sourceCloud, sourceXYZ, contactDistance, pairs);
bool frameChanged = false;
for (const InteractionPair& pair : pairs) {
const int targetObserver = matchSensor(sourceCloud, pair.observer, targetCloud);
const int targetSubject = matchSensor(sourceCloud, pair.subject, targetCloud);
if (targetObserver < 0 || targetSubject < 0) continue;
const AnimSmrSensor& subjectSensor = targetCloud.getSensor(targetSubject);
const int chainIndex = chainForTip(chains, subjectSensor.boneIndex);
if (chainIndex < 0) continue;
const Vec3 observerWorld{targetXYZ[static_cast<size_t>(targetObserver) * 3],
targetXYZ[static_cast<size_t>(targetObserver) * 3 + 1],
targetXYZ[static_cast<size_t>(targetObserver) * 3 + 2]};
const Vec3 desiredSubject = add(observerWorld, mul(pair.relative, sizeScale));
const TransformTRS& tipWorld = targetPose.world(subjectSensor.boneIndex);
const Vec3 tipPos{tipWorld.px, tipWorld.py, tipWorld.pz};
const Vec3 sensorWorld{targetXYZ[static_cast<size_t>(targetSubject) * 3],
targetXYZ[static_cast<size_t>(targetSubject) * 3 + 1],
targetXYZ[static_cast<size_t>(targetSubject) * 3 + 2]};
const Vec3 tipTarget = sub(desiredSubject, sub(sensorWorld, tipPos));
const ResolvedChain& chain = chains[static_cast<size_t>(chainIndex)];
if (targetPose.solveTwoBoneIK(targetSkeleton, chain.root, chain.mid, chain.tip, tipTarget.x, tipTarget.y,
tipTarget.z, weight)) {
writeRotationKey(targetClip, chain.root, frame, time, targetPose.local(chain.root));
writeRotationKey(targetClip, chain.mid, frame, time, targetPose.local(chain.mid));
writeRotationKey(targetClip, chain.tip, frame, time, targetPose.local(chain.tip));
targetPose.computeWorld(targetSkeleton);
targetCloud.evaluateWorldPositions(&targetPose, targetXYZ);
frameChanged = true;
}
}
if (frameChanged) ++corrections;
}
profile.interactionCorrectionCount_ = corrections;
return corrections;
}
} // namespace eve::animation
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "animation/AnimMath.h"
#include <string>
#include <vector>
namespace eve::animation {
class AnimClip;
class AnimPose;
class AnimRetargetProfile;
class AnimSkeleton;
/** @brief Coarse body-part tag used to select MeshRet-style interaction pairs. */
enum class AnimSmrBodyPart {
Torso = 0,
Arm,
Leg,
Head,
Other,
};
/**
* @brief One semantically consistent sensor attached to a bone (classical SCS stand-in).
*
* MeshRet places sensors via bone-medial ray casts into the mesh. This engine port attaches
* bind-local offsets to bones and tags them by body part so interaction pairs stay stable
* across differently proportioned skeletons.
*/
struct AnimSmrSensor {
int boneIndex = -1;
AnimSmrBodyPart part = AnimSmrBodyPart::Other;
float localX = 0.f;
float localY = 0.f;
float localZ = 0.f;
std::string semanticKey; ///< Normalized bone name + sample id for cross-skeleton match.
};
/** @brief Two-bone IK chain used to realize an interaction correction. */
struct AnimSmrIkChain {
std::string rootBone;
std::string midBone;
std::string tipBone;
};
/**
* @brief Bone-attached sensor cloud for skinned-motion-retarget interaction queries.
* Script type: `AnimSmrSensorCloud`.
*/
class AnimSmrSensorCloud {
public:
/**
* @brief Build sensors for every bone: one at the joint and one mid-segment toward the first child.
* @param skeleton Non-null borrowed skeleton; not retained.
* @throws Exception if skeleton is null.
* @thread Owner thread.
*/
static AnimSmrSensorCloud fromSkeleton(const AnimSkeleton* skeleton);
/**
* @brief Build denser MeshRet-style SCS rings around each bone segment.
* @param ringsPerBone Number of rings along each parent→child segment (>=1).
* @param pointsPerRing Samples around each ring (>=3).
* @throws Exception if skeleton is null or ring counts are invalid.
*/
static AnimSmrSensorCloud fromSkeletonDense(const AnimSkeleton* skeleton, int ringsPerBone, int pointsPerRing);
int getSensorCount() const { return static_cast<int>(sensors_.size()); }
const AnimSmrSensor& getSensor(int index) const;
AnimSmrBodyPart getSensorPart(int index) const;
/**
* @brief Evaluate world-space sensor positions for a pose that already has computeWorld() applied.
* @param outXYZ Packed xyz triples; resized to getSensorCount()*3.
* @throws Exception if pose is null.
*/
void evaluateWorldPositions(const AnimPose* pose, std::vector<float>& outXYZ) const;
private:
std::vector<AnimSmrSensor> sensors_;
};
/**
* @brief Refine an FK-retargeted clip so source body-part proximity/contact is preserved on the target.
*
* Classical stand-in for MeshRet's DMI alignment: detect close sensor pairs on the source,
* map them to the target, and pull tip bones with two-bone IK. Returns the number of
* frames that received at least one chain correction.
*
* @throws Exception on null skeleton arguments.
* @thread Owner thread; not re-entrant on the same targetClip.
*/
int smrRefineRetargetedClip(const AnimClip& sourceClip, AnimClip& targetClip, const AnimSkeleton* sourceSkeleton,
const AnimSkeleton* targetSkeleton, AnimRetargetProfile& profile);
} // namespace eve::animation
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include "common/Result.h"
#include <cstddef>
#include <string>
namespace eve::animation {
class AnimClip;
class AnimRetargetProfile;
class AnimSkeleton;
class AnimSkin;
/**
* @brief Inputs for neural MeshRet-style skinned motion retarget.
* @note All pointers are borrowed for the duration of ISmrNeuralRetarget::retarget only.
* @thread Owner thread of the animation/tensor composition; no re-entry on targetClip.
*/
struct SmrNeuralRequest {
const AnimClip* sourceClip = nullptr;
AnimClip* targetClip = nullptr; ///< FK-retargeted clip; overwritten on success.
const AnimSkeleton* sourceSkeleton = nullptr;
const AnimSkeleton* targetSkeleton = nullptr;
const AnimRetargetProfile* profile = nullptr;
const AnimSkin* sourceSkin = nullptr; ///< Optional LBS skin for denser SCS.
const AnimSkin* targetSkin = nullptr;
};
/**
* @brief Owning diagnostics from one neural retarget call.
*/
struct SmrNeuralResult {
int framesWritten = 0;
std::string backend; ///< "onnx" or "tensor-meshret".
std::size_t sensorCount = 0;
std::size_t pairCount = 0;
};
/**
* @brief Optional MeshRet/SMR neural retarget provider (implemented by animation_tensor).
*
* Animation stays free of tensor includes; the L6 satellite registers this capability.
* When absent or not ready, classical two-bone IK SMR remains the active path.
*
* @ownership Provider is owned by AnimationTensor; revoked before destruction.
* @thread Composition/owner thread only. No callbacks, script re-entry, or background work.
*/
class ISmrNeuralRetarget {
public:
static constexpr const char* capabilityName = "animation.ISmrNeuralRetarget";
virtual ~ISmrNeuralRetarget() = default;
/** @brief Stable backend label describing the live execution path. */
[[nodiscard]] virtual std::string name() const = 0;
/**
* @brief True when a model is loaded (ONNX path) or the builtin tensor MeshRet graph is usable.
* @param profile Borrowed for the call only; nullptr means not ready.
* @note Does not retain profile; safe to call without a clip.
* @ownership Does not retain profile.
* @lifetime profile must outlive this call.
* @thread Owner thread.
*/
[[nodiscard]] virtual bool isReady(const AnimRetargetProfile* profile) const = 0;
/**
* @brief Run MeshRet-style inference and rewrite targetClip local rotations.
* @return Owning diagnostics, or structured failure without mutating targetClip.
* @lifetime See SmrNeuralRequest; pointers borrowed for this call only.
* @thread Owner thread; not re-entrant on targetClip.
*/
[[nodiscard]] virtual Result<SmrNeuralResult> retarget(const SmrNeuralRequest& request) = 0;
};
} // namespace eve::animation
@@ -22,6 +22,22 @@ void exposeAnimClipBindings(ssq::Table& table) {
retarget.addFunc("getRootVerticalScale", &AnimRetargetProfile::getRootVerticalScale);
retarget.addFunc("setUseSkeletonSpaceRotation", &AnimRetargetProfile::setUseSkeletonSpaceRotation);
retarget.addFunc("getUseSkeletonSpaceRotation", &AnimRetargetProfile::getUseSkeletonSpaceRotation);
retarget.addFunc("setSkinnedInteractionPreserve", &AnimRetargetProfile::setSkinnedInteractionPreserve);
retarget.addFunc("getSkinnedInteractionPreserve", &AnimRetargetProfile::getSkinnedInteractionPreserve);
retarget.addFunc("setInteractionContactThreshold", &AnimRetargetProfile::setInteractionContactThreshold);
retarget.addFunc("getInteractionContactThreshold", &AnimRetargetProfile::getInteractionContactThreshold);
retarget.addFunc("setInteractionCorrectionWeight", &AnimRetargetProfile::setInteractionCorrectionWeight);
retarget.addFunc("getInteractionCorrectionWeight", &AnimRetargetProfile::getInteractionCorrectionWeight);
retarget.addFunc("addInteractionIkChain", &AnimRetargetProfile::addInteractionIkChain);
retarget.addFunc("clearInteractionIkChains", &AnimRetargetProfile::clearInteractionIkChains);
retarget.addFunc("getInteractionCorrectionCount", &AnimRetargetProfile::getInteractionCorrectionCount);
retarget.addFunc("setNeuralModelPath", &AnimRetargetProfile::setNeuralModelPath);
retarget.addFunc("getNeuralModelPath", &AnimRetargetProfile::getNeuralModelPath);
retarget.addFunc("setNeuralRetargetEnabled", &AnimRetargetProfile::setNeuralRetargetEnabled);
retarget.addFunc("getNeuralRetargetEnabled", &AnimRetargetProfile::getNeuralRetargetEnabled);
retarget.addFunc("setNeuralBackend", &AnimRetargetProfile::setNeuralBackend);
retarget.addFunc("getNeuralBackend", &AnimRetargetProfile::getNeuralBackend);
retarget.addFunc("getNeuralInferenceCount", &AnimRetargetProfile::getNeuralInferenceCount);
retarget.addFunc("getMatchedBoneCount", &AnimRetargetProfile::getMatchedBoneCount);
retarget.addFunc("getUnmatchedBoneCount", &AnimRetargetProfile::getUnmatchedBoneCount);
retarget.addFunc("getUnmatchedTargetBone", &AnimRetargetProfile::getUnmatchedTargetBone);
@@ -0,0 +1,33 @@
#include "animation/tensor/AnimationTensor.h"
#include "animation/AnimSmrNeural.h"
#include "animation/tensor/SmrNeuralProvider.h"
#include "common/Capability.h"
#include <simplesquirrel/simplesquirrel.hpp>
namespace eve::animation {
struct AnimationTensor::Impl {
std::unique_ptr<SmrNeuralProvider> provider;
};
Module_IMPL(AnimationTensor, new AnimationTensor());
AnimationTensor::AnimationTensor() : impl_(std::make_unique<Impl>()) {
impl_->provider = std::make_unique<SmrNeuralProvider>();
cap::provide<ISmrNeuralRetarget>(impl_->provider.get());
}
AnimationTensor::~AnimationTensor() {
if (impl_ && impl_->provider) cap::revoke<ISmrNeuralRetarget>(impl_->provider.get());
}
void AnimationTensor::expose(ssq::Table& table) {
auto cls = table.addClass(name, AnimationTensor::create, false);
expose(cls);
}
void AnimationTensor::expose(ssq::Class& cls) { cls.addFunc("getName", &AnimationTensor::getName); }
} // namespace eve::animation
@@ -0,0 +1,24 @@
#pragma once
#include "common/Module.h"
#include <memory>
namespace eve::animation {
/**
* @brief Manager-owned MeshRet neural retarget satellite (animation + tensor).
* @thread Owner/composition thread only; revokes capability before destruction.
*/
class AnimationTensor : public Module {
public:
Module_REG(AnimationTensor);
AnimationTensor();
~AnimationTensor() override;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace eve::animation
@@ -0,0 +1,254 @@
#include "animation/tensor/SmrFeatures.h"
#include "animation/AnimClip.h"
#include "animation/AnimPose.h"
#include "animation/AnimSkeleton.h"
#include "common/Exception.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <string>
#include <utility>
#include <vector>
namespace eve::animation {
namespace {
bool partsInteract(AnimSmrBodyPart a, AnimSmrBodyPart b) {
if (a > b) std::swap(a, b);
if (a == AnimSmrBodyPart::Arm &&
(b == AnimSmrBodyPart::Torso || b == AnimSmrBodyPart::Arm || b == AnimSmrBodyPart::Head))
return true;
if (a == AnimSmrBodyPart::Leg && b == AnimSmrBodyPart::Leg) return true;
if (a == AnimSmrBodyPart::Torso && b == AnimSmrBodyPart::Head) return true;
return false;
}
std::string normalizeToken(const std::string& name) {
const size_t separator = name.find_last_of(":|/");
const size_t begin = separator == std::string::npos ? 0 : separator + 1;
std::string out;
out.reserve(name.size() - begin);
for (size_t i = begin; i < name.size(); ++i) {
const unsigned char c = static_cast<unsigned char>(name[i]);
if (std::isalnum(c)) out.push_back(static_cast<char>(std::tolower(c)));
}
return out;
}
int matchJoint(const AnimSkeleton* source, const AnimSkeleton* target, int targetBone) {
const int exact = source->findBone(target->getBoneName(targetBone));
if (exact >= 0) return exact;
const std::string want = normalizeToken(target->getBoneName(targetBone));
for (int bone = 0; bone < source->getBoneCount(); ++bone) {
if (normalizeToken(source->getBoneName(bone)) == want) return bone;
}
return -1;
}
} // namespace
void quatToRot6d(float qx, float qy, float qz, float qw, float out[6]) {
const float x = qx, y = qy, z = qz, w = qw;
const float x2 = x + x, y2 = y + y, z2 = z + z;
const float xx = x * x2, xy = x * y2, xz = x * z2;
const float yy = y * y2, yz = y * z2, zz = z * z2;
const float wx = w * x2, wy = w * y2, wz = w * z2;
out[0] = 1.f - (yy + zz);
out[1] = xy + wz;
out[2] = xz - wy;
out[3] = xy - wz;
out[4] = 1.f - (xx + zz);
out[5] = yz + wx;
}
void rot6dToQuat(const float in[6], float& qx, float& qy, float& qz, float& qw) {
float c0[3] = {in[0], in[1], in[2]};
float c1[3] = {in[3], in[4], in[5]};
auto normalize = [](float* v) {
const float n = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
if (n < 1e-8f) {
v[0] = 1.f;
v[1] = 0.f;
v[2] = 0.f;
return;
}
v[0] /= n;
v[1] /= n;
v[2] /= n;
};
normalize(c0);
const float dot = c0[0] * c1[0] + c0[1] * c1[1] + c0[2] * c1[2];
c1[0] -= dot * c0[0];
c1[1] -= dot * c0[1];
c1[2] -= dot * c0[2];
normalize(c1);
const float c2[3] = {c0[1] * c1[2] - c0[2] * c1[1], c0[2] * c1[0] - c0[0] * c1[2], c0[0] * c1[1] - c0[1] * c1[0]};
const float m00 = c0[0], m01 = c1[0], m02 = c2[0];
const float m10 = c0[1], m11 = c1[1], m12 = c2[1];
const float m20 = c0[2], m21 = c1[2], m22 = c2[2];
const float trace = m00 + m11 + m22;
if (trace > 0.f) {
const float s = 0.5f / std::sqrt(trace + 1.f);
qw = 0.25f / s;
qx = (m21 - m12) * s;
qy = (m02 - m20) * s;
qz = (m10 - m01) * s;
} else if (m00 > m11 && m00 > m22) {
const float s = 2.f * std::sqrt(1.f + m00 - m11 - m22);
qw = (m21 - m12) / s;
qx = 0.25f * s;
qy = (m01 + m10) / s;
qz = (m02 + m20) / s;
} else if (m11 > m22) {
const float s = 2.f * std::sqrt(1.f + m11 - m00 - m22);
qw = (m02 - m20) / s;
qx = (m01 + m10) / s;
qy = 0.25f * s;
qz = (m12 + m21) / s;
} else {
const float s = 2.f * std::sqrt(1.f + m22 - m00 - m11);
qw = (m10 - m01) / s;
qx = (m02 + m20) / s;
qy = (m12 + m21) / s;
qz = 0.25f * s;
}
const float n = std::sqrt(qx * qx + qy * qy + qz * qz + qw * qw);
if (n > 1e-8f) {
qx /= n;
qy /= n;
qz /= n;
qw /= n;
} else {
qx = qy = qz = 0.f;
qw = 1.f;
}
}
SmrFeatureBatch buildSmrFeatures(const AnimClip& sourceClip, const AnimSkeleton* sourceSkeleton,
const AnimSkeleton* targetSkeleton, const AnimSkin* /*sourceSkin*/,
const AnimSkin* /*targetSkin*/, int ringsPerBone, int pointsPerRing) {
if (!sourceSkeleton || !targetSkeleton) throw Exception("buildSmrFeatures: skeleton is null");
SmrFeatureBatch batch;
batch.joints = targetSkeleton->getBoneCount();
batch.jointMap.assign(static_cast<size_t>(batch.joints), -1);
for (int t = 0; t < batch.joints; ++t)
batch.jointMap[static_cast<size_t>(t)] = matchJoint(sourceSkeleton, targetSkeleton, t);
const AnimSmrSensorCloud sourceCloud =
AnimSmrSensorCloud::fromSkeletonDense(sourceSkeleton, ringsPerBone, pointsPerRing);
const AnimSmrSensorCloud targetCloud =
AnimSmrSensorCloud::fromSkeletonDense(targetSkeleton, ringsPerBone, pointsPerRing);
batch.sensors = std::min(sourceCloud.getSensorCount(), targetCloud.getSensorCount());
if (batch.sensors <= 0) {
batch.sensors = 1;
batch.sourceGeom.assign(7u, 0.f);
batch.targetGeom.assign(7u, 0.f);
} else {
batch.sourceGeom.assign(static_cast<size_t>(batch.sensors) * 7u, 0.f);
batch.targetGeom.assign(static_cast<size_t>(batch.sensors) * 7u, 0.f);
}
AnimPose sourceBind;
AnimPose targetBind;
sourceSkeleton->applyBindPose(&sourceBind);
targetSkeleton->applyBindPose(&targetBind);
sourceBind.computeWorld(sourceSkeleton);
targetBind.computeWorld(targetSkeleton);
if (sourceCloud.getSensorCount() > 0 && targetCloud.getSensorCount() > 0) {
std::vector<float> sourceRest;
std::vector<float> targetRest;
sourceCloud.evaluateWorldPositions(&sourceBind, sourceRest);
targetCloud.evaluateWorldPositions(&targetBind, targetRest);
for (int i = 0; i < batch.sensors; ++i) {
for (int k = 0; k < 3; ++k) {
batch.sourceGeom[static_cast<size_t>(i) * 7u + static_cast<size_t>(k)] =
sourceRest[static_cast<size_t>(i) * 3u + static_cast<size_t>(k)];
batch.targetGeom[static_cast<size_t>(i) * 7u + static_cast<size_t>(k)] =
targetRest[static_cast<size_t>(i) * 3u + static_cast<size_t>(k)];
}
batch.sourceGeom[static_cast<size_t>(i) * 7u + 3u] = static_cast<float>(sourceCloud.getSensorPart(i));
batch.targetGeom[static_cast<size_t>(i) * 7u + 3u] = static_cast<float>(targetCloud.getSensorPart(i));
batch.sourceGeom[static_cast<size_t>(i) * 7u + 4u] = static_cast<float>(sourceCloud.getSensor(i).boneIndex);
batch.targetGeom[static_cast<size_t>(i) * 7u + 4u] = static_cast<float>(targetCloud.getSensor(i).boneIndex);
}
}
std::vector<std::pair<int, int>> pairIdx;
const int sensorLimit = std::min(batch.sensors, sourceCloud.getSensorCount());
for (int a = 0; a < sensorLimit; ++a) {
for (int b = a + 1; b < sensorLimit; ++b) {
if (partsInteract(sourceCloud.getSensorPart(a), sourceCloud.getSensorPart(b))) pairIdx.emplace_back(a, b);
}
}
batch.pairs = static_cast<int>(pairIdx.size());
const float duration = sourceClip.getDuration();
const float rate = std::max(sourceClip.getSampleRate(), 1.f);
batch.frames = duration <= 0.f ? 1 : std::max(1, static_cast<int>(std::ceil(duration * rate)) + 1);
batch.sourceRot6d.assign(static_cast<size_t>(batch.frames) * static_cast<size_t>(batch.joints) * 6u, 0.f);
batch.sourceDmi.assign(static_cast<size_t>(batch.frames) * static_cast<size_t>(std::max(batch.pairs, 1)) * 10u,
0.f);
AnimPose pose;
std::vector<float> xyz;
std::vector<float> prevXyz;
for (int frame = 0; frame < batch.frames; ++frame) {
const float time =
duration <= 0.f ? 0.f
: duration * static_cast<float>(frame) / static_cast<float>(std::max(batch.frames - 1, 1));
sourceClip.sample(time, &pose, sourceSkeleton);
pose.computeWorld(sourceSkeleton);
for (int joint = 0; joint < batch.joints; ++joint) {
const int sourceJoint = batch.jointMap[static_cast<size_t>(joint)];
float rot[6] = {1.f, 0.f, 0.f, 0.f, 1.f, 0.f};
if (sourceJoint >= 0) {
const TransformTRS& local = pose.local(sourceJoint);
quatToRot6d(local.qx, local.qy, local.qz, local.qw, rot);
}
const size_t base =
(static_cast<size_t>(frame) * static_cast<size_t>(batch.joints) + static_cast<size_t>(joint)) * 6u;
for (int k = 0; k < 6; ++k) batch.sourceRot6d[base + static_cast<size_t>(k)] = rot[k];
}
if (sourceCloud.getSensorCount() > 0) sourceCloud.evaluateWorldPositions(&pose, xyz);
for (int p = 0; p < batch.pairs; ++p) {
const int a = pairIdx[static_cast<size_t>(p)].first;
const int b = pairIdx[static_cast<size_t>(p)].second;
const float ax = xyz[static_cast<size_t>(a) * 3u];
const float ay = xyz[static_cast<size_t>(a) * 3u + 1u];
const float az = xyz[static_cast<size_t>(a) * 3u + 2u];
const float bx = xyz[static_cast<size_t>(b) * 3u];
const float by = xyz[static_cast<size_t>(b) * 3u + 1u];
const float bz = xyz[static_cast<size_t>(b) * 3u + 2u];
const float dx = bx - ax, dy = by - ay, dz = bz - az;
const float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
float vx = 0.f, vy = 0.f, vz = 0.f;
if (!prevXyz.empty()) {
vx = (bx - prevXyz[static_cast<size_t>(b) * 3u]) - (ax - prevXyz[static_cast<size_t>(a) * 3u]);
vy =
(by - prevXyz[static_cast<size_t>(b) * 3u + 1u]) - (ay - prevXyz[static_cast<size_t>(a) * 3u + 1u]);
vz =
(bz - prevXyz[static_cast<size_t>(b) * 3u + 2u]) - (az - prevXyz[static_cast<size_t>(a) * 3u + 2u]);
}
const size_t base =
(static_cast<size_t>(frame) * static_cast<size_t>(batch.pairs) + static_cast<size_t>(p)) * 10u;
batch.sourceDmi[base + 0] = dx;
batch.sourceDmi[base + 1] = dy;
batch.sourceDmi[base + 2] = dz;
batch.sourceDmi[base + 3] = dist;
batch.sourceDmi[base + 4] = static_cast<float>(sourceCloud.getSensorPart(a));
batch.sourceDmi[base + 5] = static_cast<float>(sourceCloud.getSensorPart(b));
batch.sourceDmi[base + 6] = vx;
batch.sourceDmi[base + 7] = vy;
batch.sourceDmi[base + 8] = vz;
batch.sourceDmi[base + 9] = 1.f;
}
prevXyz = xyz;
}
return batch;
}
} // namespace eve::animation
@@ -0,0 +1,49 @@
#pragma once
#include "animation/AnimSmr.h"
#include <vector>
namespace eve::animation {
class AnimClip;
class AnimSkeleton;
class AnimSkin;
/** @brief Packed MeshRet-style features for one retarget inference window. */
struct SmrFeatureBatch {
int frames = 0;
int joints = 0;
int sensors = 0;
int pairs = 0;
std::vector<float> sourceRot6d; ///< [T*J*6]
std::vector<float> sourceGeom; ///< [S*7]
std::vector<float> targetGeom; ///< [S*7]
std::vector<float> sourceDmi; ///< [T*P*10]
std::vector<int> jointMap; ///< target joint -> source joint (-1 if unmatched)
};
/**
* @brief Build dense SCS + DMI tensors from skeletons/clip (no tensor headers).
* @param sourceSkeleton Borrowed; must outlive the call. Required.
* @param targetSkeleton Borrowed; must outlive the call. Required.
* @param sourceSkin Optional borrowed LBS skin for denser SCS; nullptr skips skin rings.
* @param targetSkin Optional borrowed LBS skin; nullptr skips skin rings.
* @return Owning packed feature batch.
* @throws Exception on null required skeletons or empty clip.
* @ownership Does not retain any input pointers after return.
* @lifetime All raw pointers are borrowed for the duration of this call only.
* @thread Owner thread.
*/
SmrFeatureBatch buildSmrFeatures(const AnimClip& sourceClip, const AnimSkeleton* sourceSkeleton,
const AnimSkeleton* targetSkeleton, const AnimSkin* sourceSkin,
const AnimSkin* targetSkin, int ringsPerBone = 2, int pointsPerRing = 4);
/** @brief Convert unit quaternion to MeshRet-style rotation-6D. */
void quatToRot6d(float qx, float qy, float qz, float qw, float out[6]);
/** @brief Convert rotation-6D back to a unit quaternion. */
void rot6dToQuat(const float in[6], float& qx, float& qy, float& qz, float& qw);
} // namespace eve::animation
@@ -0,0 +1,267 @@
#include "animation/tensor/SmrMeshRetNet.h"
#include "common/Diagnostic.h"
#include "tensor/Tensor.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <random>
#include <utility>
namespace eve::animation {
namespace {
void gemm(const float* a, const float* b, float* c, int m, int k, int n) {
for (int i = 0; i < m; ++i)
for (int j = 0; j < n; ++j) {
float sum = 0.f;
for (int t = 0; t < k; ++t) sum += a[i * k + t] * b[t * n + j];
c[i * n + j] = sum;
}
}
void addBias(float* x, const float* bias, int rows, int cols) {
for (int r = 0; r < rows; ++r)
for (int c = 0; c < cols; ++c) x[r * cols + c] += bias[c];
}
void gelu(float* x, int n) {
for (int i = 0; i < n; ++i) {
const float v = x[i];
x[i] = 0.5f * v * (1.f + std::tanh(0.7978845608f * (v + 0.044715f * v * v * v)));
}
}
void layernorm(float* x, int rows, int cols, const float* g, const float* b) {
for (int r = 0; r < rows; ++r) {
float* row = x + r * cols;
float mean = 0.f;
for (int c = 0; c < cols; ++c) mean += row[c];
mean /= static_cast<float>(cols);
float var = 0.f;
for (int c = 0; c < cols; ++c) {
const float d = row[c] - mean;
var += d * d;
}
var = 1.f / std::sqrt(var / static_cast<float>(cols) + 1e-5f);
for (int c = 0; c < cols; ++c) row[c] = (row[c] - mean) * var * g[c] + b[c];
}
}
void softmaxRows(float* x, int rows, int cols) {
for (int r = 0; r < rows; ++r) {
float* row = x + r * cols;
float mx = row[0];
for (int c = 1; c < cols; ++c) mx = std::max(mx, row[c]);
float sum = 0.f;
for (int c = 0; c < cols; ++c) {
row[c] = std::exp(row[c] - mx);
sum += row[c];
}
const float inv = 1.f / std::max(sum, 1e-8f);
for (int c = 0; c < cols; ++c) row[c] *= inv;
}
}
std::vector<float> xavier(std::mt19937& rng, int rows, int cols) {
std::normal_distribution<float> dist(0.f, std::sqrt(2.f / static_cast<float>(rows + cols)));
std::vector<float> out(static_cast<size_t>(rows * cols));
for (float& v : out) v = dist(rng);
return out;
}
std::vector<float> zeros(int n) { return std::vector<float>(static_cast<size_t>(n), 0.f); }
std::vector<float> ones(int n) { return std::vector<float>(static_cast<size_t>(n), 1.f); }
std::vector<float> mlp2(const float* x, int rows, int inDim, int mid, int outDim, const std::vector<float>& w1,
const std::vector<float>& b1, const std::vector<float>& w2, const std::vector<float>& b2) {
std::vector<float> h(static_cast<size_t>(rows * mid));
gemm(x, w1.data(), h.data(), rows, inDim, mid);
addBias(h.data(), b1.data(), rows, mid);
gelu(h.data(), rows * mid);
std::vector<float> y(static_cast<size_t>(rows * outDim));
gemm(h.data(), w2.data(), y.data(), rows, mid, outDim);
addBias(y.data(), b2.data(), rows, outDim);
return y;
}
std::vector<float> maxPool(const std::vector<float>& x, int groups, int count, int dim) {
std::vector<float> out(static_cast<size_t>(groups * dim), -1e30f);
for (int g = 0; g < groups; ++g)
for (int i = 0; i < count; ++i)
for (int d = 0; d < dim; ++d) {
const float v = x[static_cast<size_t>((g * count + i) * dim + d)];
float& slot = out[static_cast<size_t>(g * dim + d)];
slot = std::max(slot, v);
}
for (float& v : out)
if (v < -1e29f) v = 0.f;
return out;
}
void transformerLayer(std::vector<float>& x, int tokens, int dim, int heads, const std::vector<float>& wq,
const std::vector<float>& wk, const std::vector<float>& wv, const std::vector<float>& wo,
const std::vector<float>& w1, const std::vector<float>& b1, const std::vector<float>& w2,
const std::vector<float>& b2, const std::vector<float>& ln1g, const std::vector<float>& ln1b,
const std::vector<float>& ln2g, const std::vector<float>& ln2b, const std::vector<float>* memory,
int memoryTokens) {
const int headDim = std::max(1, dim / std::max(heads, 1));
const int srcTokens = memory ? memoryTokens : tokens;
std::vector<float> residual = x;
layernorm(x.data(), tokens, dim, ln1g.data(), ln1b.data());
std::vector<float> q(static_cast<size_t>(tokens * dim));
std::vector<float> k(static_cast<size_t>(srcTokens * dim));
std::vector<float> v(static_cast<size_t>(srcTokens * dim));
gemm(x.data(), wq.data(), q.data(), tokens, dim, dim);
const float* src = memory ? memory->data() : x.data();
gemm(src, wk.data(), k.data(), srcTokens, dim, dim);
gemm(src, wv.data(), v.data(), srcTokens, dim, dim);
std::vector<float> ctx(static_cast<size_t>(tokens * dim), 0.f);
const float scale = 1.f / std::sqrt(static_cast<float>(headDim));
for (int h = 0; h < heads; ++h) {
std::vector<float> scores(static_cast<size_t>(tokens * srcTokens));
for (int t = 0; t < tokens; ++t)
for (int s = 0; s < srcTokens; ++s) {
float sum = 0.f;
for (int d = 0; d < headDim; ++d)
sum += q[static_cast<size_t>(t * dim + h * headDim + d)] *
k[static_cast<size_t>(s * dim + h * headDim + d)];
scores[static_cast<size_t>(t * srcTokens + s)] = sum * scale;
}
softmaxRows(scores.data(), tokens, srcTokens);
for (int t = 0; t < tokens; ++t)
for (int d = 0; d < headDim; ++d) {
float sum = 0.f;
for (int s = 0; s < srcTokens; ++s)
sum += scores[static_cast<size_t>(t * srcTokens + s)] *
v[static_cast<size_t>(s * dim + h * headDim + d)];
ctx[static_cast<size_t>(t * dim + h * headDim + d)] = sum;
}
}
std::vector<float> projected(static_cast<size_t>(tokens * dim));
gemm(ctx.data(), wo.data(), projected.data(), tokens, dim, dim);
for (size_t i = 0; i < x.size(); ++i) x[i] = residual[i] + projected[i];
residual = x;
layernorm(x.data(), tokens, dim, ln2g.data(), ln2b.data());
auto ff = mlp2(x.data(), tokens, dim, static_cast<int>(b1.size()), dim, w1, b1, w2, b2);
for (size_t i = 0; i < x.size(); ++i) x[i] = residual[i] + ff[i];
}
} // namespace
SmrMeshRetNet::SmrMeshRetNet(SmrMeshRetConfig config) : config_(config) { initWeights(); }
void SmrMeshRetNet::initWeights() {
std::mt19937 rng(static_cast<uint32_t>(config_.seed));
const int d = config_.latentDim;
geomW1_ = xavier(rng, 7, d);
geomB1_ = zeros(d);
geomW2_ = xavier(rng, d, d);
geomB2_ = zeros(d);
dmiW1_ = xavier(rng, 10, d);
dmiB1_ = zeros(d);
dmiW2_ = xavier(rng, d, d);
dmiB2_ = zeros(d);
motionW_ = xavier(rng, 6, d);
motionB_ = zeros(d);
fuseW_ = xavier(rng, d * 3, d);
fuseB_ = zeros(d);
outW_ = xavier(rng, d, 6);
outB_ = zeros(6);
auto fill = [&](std::vector<LayerWeights>& layers) {
layers.assign(static_cast<size_t>(config_.numLayers), {});
for (auto& layer : layers) {
layer.wq = xavier(rng, d, d);
layer.wk = xavier(rng, d, d);
layer.wv = xavier(rng, d, d);
layer.wo = xavier(rng, d, d);
layer.w1 = xavier(rng, d, config_.ffSize);
layer.b1 = zeros(config_.ffSize);
layer.w2 = xavier(rng, config_.ffSize, d);
layer.b2 = zeros(d);
layer.ln1g = ones(d);
layer.ln1b = zeros(d);
layer.ln2g = ones(d);
layer.ln2b = zeros(d);
}
};
fill(enc_);
fill(dec_);
tensor::Tensor probe(d);
probe.fill(0.f);
(void)probe;
}
Result<std::vector<float>> SmrMeshRetNet::forward(const SmrFeatureBatch& features) const {
if (features.frames <= 0 || features.joints <= 0) {
return Result<std::vector<float>>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "SmrMeshRetNet.forward: empty features"));
}
const int d = config_.latentDim;
const int T = features.frames;
const int J = features.joints;
const int S = std::max(features.sensors, 1);
const int P = std::max(features.pairs, 1);
const int heads = std::max(1, config_.numHeads);
auto srcGeom = mlp2(features.sourceGeom.data(), S, 7, d, d, geomW1_, geomB1_, geomW2_, geomB2_);
auto tgtGeom = mlp2(features.targetGeom.data(), S, 7, d, d, geomW1_, geomB1_, geomW2_, geomB2_);
auto srcPool = maxPool(srcGeom, 1, S, d);
auto tgtPool = maxPool(tgtGeom, 1, S, d);
std::vector<float> temporal(static_cast<size_t>(T * d), 0.f);
for (int t = 0; t < T; ++t) {
const float* dmiFrame = features.sourceDmi.data() + static_cast<size_t>(t) * static_cast<size_t>(P) * 10u;
auto dmiEnc = mlp2(dmiFrame, P, 10, d, d, dmiW1_, dmiB1_, dmiW2_, dmiB2_);
auto dmiPool = maxPool(dmiEnc, 1, P, d);
std::vector<float> meanRot(6, 0.f);
for (int j = 0; j < J; ++j) {
const float* r = features.sourceRot6d.data() + static_cast<size_t>((t * J + j) * 6);
for (int k = 0; k < 6; ++k) meanRot[static_cast<size_t>(k)] += r[k];
}
for (float& v : meanRot) v /= static_cast<float>(std::max(J, 1));
std::vector<float> motion(static_cast<size_t>(d), 0.f);
gemm(meanRot.data(), motionW_.data(), motion.data(), 1, 6, d);
addBias(motion.data(), motionB_.data(), 1, d);
std::vector<float> fusedIn(static_cast<size_t>(d * 3));
for (int i = 0; i < d; ++i) {
fusedIn[static_cast<size_t>(i)] = dmiPool[static_cast<size_t>(i)];
fusedIn[static_cast<size_t>(d + i)] = motion[static_cast<size_t>(i)];
fusedIn[static_cast<size_t>(2 * d + i)] = srcPool[static_cast<size_t>(i)];
}
std::vector<float> fused(static_cast<size_t>(d));
gemm(fusedIn.data(), fuseW_.data(), fused.data(), 1, d * 3, d);
addBias(fused.data(), fuseB_.data(), 1, d);
for (int i = 0; i < d; ++i) temporal[static_cast<size_t>(t * d + i)] = fused[static_cast<size_t>(i)];
}
std::vector<float> memory = temporal;
for (const auto& layer : enc_)
transformerLayer(memory, T, d, heads, layer.wq, layer.wk, layer.wv, layer.wo, layer.w1, layer.b1, layer.w2,
layer.b2, layer.ln1g, layer.ln1b, layer.ln2g, layer.ln2b, nullptr, 0);
std::vector<float> queries(static_cast<size_t>(T * d));
for (int t = 0; t < T; ++t)
for (int i = 0; i < d; ++i)
queries[static_cast<size_t>(t * d + i)] =
tgtPool[static_cast<size_t>(i)] + temporal[static_cast<size_t>(t * d + i)];
for (const auto& layer : dec_)
transformerLayer(queries, T, d, heads, layer.wq, layer.wk, layer.wv, layer.wo, layer.w1, layer.b1, layer.w2,
layer.b2, layer.ln1g, layer.ln1b, layer.ln2g, layer.ln2b, &memory, T);
std::vector<float> out(static_cast<size_t>(T * J * 6));
for (int t = 0; t < T; ++t) {
std::vector<float> delta(6, 0.f);
gemm(queries.data() + static_cast<size_t>(t * d), outW_.data(), delta.data(), 1, d, 6);
addBias(delta.data(), outB_.data(), 1, 6);
for (int j = 0; j < J; ++j) {
const float* src = features.sourceRot6d.data() + static_cast<size_t>((t * J + j) * 6);
float* dst = out.data() + static_cast<size_t>((t * J + j) * 6);
for (int k = 0; k < 6; ++k) dst[k] = src[k] + 0.05f * delta[static_cast<size_t>(k)];
}
}
return Result<std::vector<float>>::success(std::move(out));
}
} // namespace eve::animation
@@ -0,0 +1,49 @@
#pragma once
#include "animation/tensor/SmrFeatures.h"
#include "common/Result.h"
#include <vector>
namespace eve::animation {
/** @brief Hyperparameters for the builtin MeshRet-style graph. */
struct SmrMeshRetConfig {
int latentDim = 128;
int ffSize = 256;
int numLayers = 2;
int numHeads = 4;
int seed = 9923;
};
/**
* @brief Builtin MeshRet-style PointNet + Transformer retarget (links eve::tensor).
* @thread Owner thread; not re-entrant.
*/
class SmrMeshRetNet {
public:
explicit SmrMeshRetNet(SmrMeshRetConfig config = {});
[[nodiscard]] const SmrMeshRetConfig& config() const { return config_; }
/** @brief Owning target rot6d [T*J*6], or structured failure. */
[[nodiscard]] Result<std::vector<float>> forward(const SmrFeatureBatch& features) const;
private:
struct LayerWeights {
std::vector<float> wq, wk, wv, wo, w1, b1, w2, b2, ln1g, ln1b, ln2g, ln2b;
};
SmrMeshRetConfig config_;
std::vector<float> geomW1_, geomB1_, geomW2_, geomB2_;
std::vector<float> dmiW1_, dmiB1_, dmiW2_, dmiB2_;
std::vector<float> motionW_, motionB_;
std::vector<float> fuseW_, fuseB_;
std::vector<float> outW_, outB_;
std::vector<LayerWeights> enc_;
std::vector<LayerWeights> dec_;
void initWeights();
};
} // namespace eve::animation
@@ -0,0 +1,101 @@
#include "animation/tensor/SmrNeuralProvider.h"
#include "animation/AnimClip.h"
#include "animation/AnimSkeleton.h"
#include "common/Diagnostic.h"
#include <algorithm>
namespace eve::animation {
std::string SmrNeuralProvider::name() const { return loadedPath_.empty() ? "tensor-meshret" : "onnx"; }
bool SmrNeuralProvider::isReady(const AnimRetargetProfile* profile) const {
if (!profile) return true;
if (!profile->getNeuralRetargetEnabled()) return false;
const std::string& backend = profile->getNeuralBackend();
if (backend == "onnx") return ensureOnnx(*profile).ok();
if (backend == "tensor") return true;
if (!profile->getNeuralModelPath().empty()) return ensureOnnx(*profile).ok();
return true;
}
Result<void> SmrNeuralProvider::ensureOnnx(const AnimRetargetProfile& profile) const {
const std::string& path = profile.getNeuralModelPath();
if (path.empty()) {
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::PreconditionViolation, "SmrNeuralProvider: empty ONNX path"));
}
if (modelLoaded_ && loadedPath_ == path) return Result<void>::success();
auto loaded = onnx_.loadFile(path);
if (!loaded.ok()) return loaded;
loadedPath_ = path;
modelLoaded_ = true;
return Result<void>::success();
}
Result<SmrNeuralResult> SmrNeuralProvider::applyRot6d(const SmrNeuralRequest& request, const SmrFeatureBatch& features,
const std::vector<float>& rot6d,
const std::string& backend) const {
if (!request.targetClip || !request.targetSkeleton) {
return Result<SmrNeuralResult>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "SmrNeuralProvider: null clip/skeleton"));
}
if (static_cast<int>(rot6d.size()) < features.frames * features.joints * 6) {
return Result<SmrNeuralResult>::failure(
Diagnostic::error(DiagnosticCode::InvariantViolation, "SmrNeuralProvider: rot6d size mismatch"));
}
AnimClip& clip = *request.targetClip;
const float duration = clip.getDuration();
for (int joint = 0; joint < features.joints; ++joint) {
clip.clearTrack(joint);
for (int frame = 0; frame < features.frames; ++frame) {
const float time = duration <= 0.f ? 0.f
: duration * static_cast<float>(frame) /
static_cast<float>(std::max(features.frames - 1, 1));
const float* r6 = rot6d.data() + static_cast<size_t>((frame * features.joints + joint) * 6);
float qx = 0.f, qy = 0.f, qz = 0.f, qw = 1.f;
rot6dToQuat(r6, qx, qy, qz, qw);
clip.addRotationKey(joint, time, qx, qy, qz, qw);
}
}
SmrNeuralResult result;
result.framesWritten = features.frames;
result.backend = backend;
result.sensorCount = static_cast<size_t>(features.sensors);
result.pairCount = static_cast<size_t>(features.pairs);
return Result<SmrNeuralResult>::success(std::move(result));
}
Result<SmrNeuralResult> SmrNeuralProvider::retarget(const SmrNeuralRequest& request) {
if (!request.sourceClip || !request.targetClip || !request.sourceSkeleton || !request.targetSkeleton ||
!request.profile) {
return Result<SmrNeuralResult>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "SmrNeuralProvider.retarget: null argument"));
}
SmrFeatureBatch features = buildSmrFeatures(*request.sourceClip, request.sourceSkeleton, request.targetSkeleton,
request.sourceSkin, request.targetSkin, 2, 4);
const std::string& backendHint = request.profile->getNeuralBackend();
const bool preferOnnx =
backendHint == "onnx" || (backendHint != "tensor" && !request.profile->getNeuralModelPath().empty());
if (preferOnnx) {
auto ready = ensureOnnx(*request.profile);
if (ready.ok()) {
auto ran = onnx_.run(features);
if (ran.ok()) return applyRot6d(request, features, ran.value(), "onnx");
if (backendHint == "onnx") return Result<SmrNeuralResult>::failure(ran.status());
} else if (backendHint == "onnx") {
return Result<SmrNeuralResult>::failure(ready.status());
}
}
auto ran = net_.forward(features);
if (!ran.ok()) return Result<SmrNeuralResult>::failure(ran.status());
return applyRot6d(request, features, ran.value(), "tensor-meshret");
}
} // namespace eve::animation
@@ -0,0 +1,51 @@
#pragma once
#include "animation/AnimSmrNeural.h"
#include "animation/tensor/SmrFeatures.h"
#include "animation/tensor/SmrMeshRetNet.h"
#include "animation/tensor/SmrOnnxRunner.h"
#include <string>
#include <vector>
namespace eve::animation {
/**
* @brief Capability provider selecting ONNX MeshRet or builtin tensor MeshRet.
* @ownership Owned by AnimationTensor; registered via cap::provide until revoke.
* @thread Owner/composition thread only; no callbacks while holding locks.
*/
class SmrNeuralProvider final : public ISmrNeuralRetarget {
public:
SmrNeuralProvider() = default;
[[nodiscard]] std::string name() const override;
/**
* @brief True when ONNX is loadable for the profile path, or builtin MeshRet is usable.
* @param profile Borrowed for the call only; nullptr means not ready.
* @ownership Does not retain profile.
* @lifetime profile must outlive this call.
* @thread Owner thread.
*/
[[nodiscard]] bool isReady(const AnimRetargetProfile* profile) const override;
/**
* @brief Run inference and rewrite request.targetClip local rotations.
* @lifetime See SmrNeuralRequest; pointers borrowed for this call only.
* @thread Owner thread; not re-entrant on targetClip.
*/
[[nodiscard]] Result<SmrNeuralResult> retarget(const SmrNeuralRequest& request) override;
private:
mutable SmrOnnxRunner onnx_;
mutable std::string loadedPath_;
mutable bool modelLoaded_ = false;
SmrMeshRetNet net_{};
[[nodiscard]] Result<void> ensureOnnx(const AnimRetargetProfile& profile) const;
[[nodiscard]] Result<SmrNeuralResult> applyRot6d(const SmrNeuralRequest& request, const SmrFeatureBatch& features,
const std::vector<float>& rot6d, const std::string& backend) const;
};
} // namespace eve::animation
@@ -0,0 +1,87 @@
#include "animation/tensor/SmrOnnxRunner.h"
#include "common/Diagnostic.h"
#include <cstring>
#include <fstream>
#include <vector>
namespace eve::animation {
namespace {
tensor::OnnxNamedTensor makeF32(const char* name, std::vector<int64_t> shape, const std::vector<float>& data) {
tensor::OnnxNamedTensor named;
named.name = name;
named.tensor.element = tensor::OnnxElement::Float32;
named.tensor.shape = std::move(shape);
named.tensor.bytes.resize(data.size() * sizeof(float));
if (!data.empty()) std::memcpy(named.tensor.bytes.data(), data.data(), named.tensor.bytes.size());
return named;
}
} // namespace
Result<void> SmrOnnxRunner::load(std::span<const uint8_t> bytes) {
auto loaded = tensor::OnnxModel::load(bytes);
if (!loaded.ok()) return Result<void>::failure(loaded.status());
model_ = std::move(loaded.value());
return Result<void>::success();
}
Result<void> SmrOnnxRunner::loadFile(const std::string& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) {
return Result<void>::failure(Diagnostic::error(DiagnosticCode::NotFound, "SmrOnnxRunner: open failed", path));
}
const auto size = static_cast<std::streamoff>(file.tellg());
if (size <= 0) {
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "SmrOnnxRunner: empty file", path));
}
std::vector<uint8_t> bytes(static_cast<size_t>(size));
file.seekg(0);
if (!file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
return Result<void>::failure(Diagnostic::error(DiagnosticCode::Failed, "SmrOnnxRunner: read failed", path));
}
return load(bytes);
}
tensor::OnnxModelInfo SmrOnnxRunner::info() const {
if (!model_) return {};
return model_->info();
}
Result<std::vector<float>> SmrOnnxRunner::run(const SmrFeatureBatch& features) const {
if (!model_) {
return Result<std::vector<float>>::failure(
Diagnostic::error(DiagnosticCode::PreconditionViolation, "SmrOnnxRunner: no model"));
}
if (features.frames <= 0 || features.joints <= 0) {
return Result<std::vector<float>>::failure(
Diagnostic::error(DiagnosticCode::InvalidArgument, "SmrOnnxRunner: empty features"));
}
const int64_t T = features.frames;
const int64_t J = features.joints;
const int64_t S = std::max(features.sensors, 1);
const int64_t P = std::max(features.pairs, 1);
std::vector<tensor::OnnxNamedTensor> feeds;
feeds.push_back(makeF32("source_rot6d", {1, T, J, 6}, features.sourceRot6d));
feeds.push_back(makeF32("source_geom", {1, S, 7}, features.sourceGeom));
feeds.push_back(makeF32("target_geom", {1, S, 7}, features.targetGeom));
feeds.push_back(makeF32("source_dmi", {1, T, P, 10}, features.sourceDmi));
const std::string outName = "target_rot6d";
auto ran = model_->run(feeds, std::span<const std::string>(&outName, 1));
if (!ran.ok()) return Result<std::vector<float>>::failure(ran.status());
if (ran.value().empty()) {
return Result<std::vector<float>>::failure(
Diagnostic::error(DiagnosticCode::Failed, "SmrOnnxRunner: missing output"));
}
const auto& bytes = ran.value()[0].tensor.bytes;
std::vector<float> out(bytes.size() / sizeof(float));
if (!out.empty()) std::memcpy(out.data(), bytes.data(), out.size() * sizeof(float));
return Result<std::vector<float>>::success(std::move(out));
}
} // namespace eve::animation
@@ -0,0 +1,37 @@
#pragma once
#include "animation/tensor/SmrFeatures.h"
#include "common/Result.h"
#include "tensor/OnnxModel.h"
#include <memory>
#include <span>
#include <string>
#include <vector>
namespace eve::animation {
/**
* @brief MeshRet-compatible ONNX runner on CPU via eve::tensor::OnnxModel.
*
* I/O contract from scripts/export_meshret_onnx.py:
* - inputs: source_rot6d [1,T,J,6], source_geom [1,S,7], target_geom [1,S,7], source_dmi [1,T,P,10]
* - output: target_rot6d [1,T,J,6]
*
* @ownership Owns the loaded OnnxModel until load/reload or destruction.
* @lifetime Model bytes are copied at load; feature tensors are borrowed only for run().
* @thread Owner thread; not re-entrant.
*/
class SmrOnnxRunner {
public:
[[nodiscard]] Result<void> load(std::span<const uint8_t> bytes);
[[nodiscard]] Result<void> loadFile(const std::string& path);
[[nodiscard]] bool isLoaded() const { return static_cast<bool>(model_); }
[[nodiscard]] tensor::OnnxModelInfo info() const;
[[nodiscard]] Result<std::vector<float>> run(const SmrFeatureBatch& features) const;
private:
std::unique_ptr<tensor::OnnxModel> model_;
};
} // namespace eve::animation
+120
View File
@@ -0,0 +1,120 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "animation/AnimClip.h"
#include "animation/AnimPose.h"
#include "animation/AnimSkeleton.h"
#include "animation/AnimSmr.h"
#include "animation/AnimSmrNeural.h"
#include "animation/tensor/AnimationTensor.h"
#include "animation/tensor/SmrFeatures.h"
#include "common/Capability.h"
#include <cmath>
#include <memory>
using namespace eve::animation;
namespace {
void buildArmature(AnimSkeleton& skel, float torsoHeight, float upperArm, float lowerArm) {
const int root = skel.addBone("Hips");
const int chest = skel.addBone("Spine", root);
const int shoulder = skel.addBone("LeftShoulder", chest);
const int elbow = skel.addBone("LeftElbow", shoulder);
const int hand = skel.addBone("LeftHand", elbow);
skel.setBindPosition(chest, 0.f, torsoHeight, 0.f);
skel.setBindPosition(shoulder, 0.15f, 0.f, 0.f);
skel.setBindPosition(elbow, upperArm, 0.f, 0.f);
skel.setBindPosition(hand, lowerArm, 0.f, 0.f);
}
} // namespace
TEST_CASE("animation.smr.neural.rot6dRoundTrip") {
float out[6] = {};
quatToRot6d(0.f, 0.f, 0.70710678f, 0.70710678f, out);
float qx = 0.f, qy = 0.f, qz = 0.f, qw = 1.f;
rot6dToQuat(out, qx, qy, qz, qw);
const float dot = std::fabs(qx * 0.f + qy * 0.f + qz * 0.70710678f + qw * 0.70710678f);
CHECK(dot > 0.999f);
}
TEST_CASE("animation.smr.neural.featureBatchShapes") {
AnimSkeleton source;
AnimSkeleton target;
buildArmature(source, 1.0f, 0.28f, 0.28f);
buildArmature(target, 1.8f, 0.70f, 0.70f);
AnimClip clip("reach");
clip.setDuration(0.1f);
clip.setSampleRate(10.f);
clip.addRotationKey(source.findBone("LeftShoulder"), 0.f, 0.f, 0.f, 0.70710678f, 0.70710678f);
clip.addRotationKey(source.findBone("LeftElbow"), 0.05f, 0.f, 0.f, 0.70710678f, 0.70710678f);
const SmrFeatureBatch batch = buildSmrFeatures(clip, &source, &target, nullptr, nullptr, 2, 4);
CHECK(batch.frames >= 1);
CHECK(batch.joints == target.getBoneCount());
CHECK(batch.sensors >= 1);
CHECK(static_cast<int>(batch.sourceRot6d.size()) == batch.frames * batch.joints * 6);
CHECK(static_cast<int>(batch.sourceGeom.size()) == batch.sensors * 7);
CHECK(static_cast<int>(batch.targetGeom.size()) == batch.sensors * 7);
CHECK(static_cast<int>(batch.sourceDmi.size()) == batch.frames * std::max(batch.pairs, 1) * 10);
}
TEST_CASE("animation.smr.neural.tensorBackendRewritesClip") {
AnimationTensor* module = AnimationTensor::create();
CHECK(module != nullptr);
CHECK(eve::cap::query<ISmrNeuralRetarget>() != nullptr);
AnimSkeleton source;
AnimSkeleton target;
buildArmature(source, 1.0f, 0.28f, 0.28f);
buildArmature(target, 1.8f, 0.70f, 0.70f);
AnimClip clip("reach");
clip.setDuration(0.f);
clip.setSampleRate(30.f);
clip.addRotationKey(source.findBone("LeftShoulder"), 0.f, 0.f, 0.f, 0.70710678f, 0.70710678f);
clip.addRotationKey(source.findBone("LeftElbow"), 0.f, 0.f, 0.f, 0.70710678f, 0.70710678f);
AnimRetargetProfile neural;
neural.setSkinnedInteractionPreserve(true);
neural.setNeuralRetargetEnabled(true);
neural.setNeuralBackend("tensor");
neural.addInteractionIkChain("LeftShoulder", "LeftElbow", "LeftHand");
std::unique_ptr<AnimClip> out(clip.retargetWithProfile(&source, &target, &neural));
CHECK(out != nullptr);
CHECK(neural.getNeuralInferenceCount() >= 1);
CHECK(neural.getInteractionCorrectionCount() >= 1);
CHECK(out->getRotationKeyCount(target.findBone("LeftElbow")) >= 1);
}
TEST_CASE("animation.smr.neural.fallsBackToClassicalWhenDisabled") {
AnimationTensor* module = AnimationTensor::create();
CHECK(module != nullptr);
AnimSkeleton source;
AnimSkeleton target;
buildArmature(source, 1.0f, 0.28f, 0.28f);
buildArmature(target, 1.8f, 0.70f, 0.70f);
AnimClip clip("reach");
clip.setDuration(0.f);
clip.setSampleRate(30.f);
clip.addRotationKey(source.findBone("LeftShoulder"), 0.f, 0.f, 0.f, 0.70710678f, 0.70710678f);
clip.addRotationKey(source.findBone("LeftElbow"), 0.f, 0.f, 0.f, 0.70710678f, 0.70710678f);
AnimRetargetProfile classical;
classical.setSkinnedInteractionPreserve(true);
classical.setNeuralRetargetEnabled(false);
classical.setInteractionContactThreshold(0.4f);
classical.setInteractionCorrectionWeight(1.f);
classical.addInteractionIkChain("LeftShoulder", "LeftElbow", "LeftHand");
std::unique_ptr<AnimClip> out(clip.retargetWithProfile(&source, &target, &classical));
CHECK(out != nullptr);
CHECK(classical.getNeuralInferenceCount() == 0);
CHECK(classical.getInteractionCorrectionCount() >= 1);
}
+109
View File
@@ -0,0 +1,109 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "animation/AnimClip.h"
#include "animation/AnimPose.h"
#include "animation/AnimSkeleton.h"
#include "animation/AnimSmr.h"
#include <cmath>
#include <memory>
using namespace eve::animation;
namespace {
void buildArmature(AnimSkeleton& skel, float torsoHeight, float upperArm, float lowerArm) {
const int root = skel.addBone("Hips");
const int chest = skel.addBone("Spine", root);
const int shoulder = skel.addBone("LeftShoulder", chest);
const int elbow = skel.addBone("LeftElbow", shoulder);
const int hand = skel.addBone("LeftHand", elbow);
skel.setBindPosition(chest, 0.f, torsoHeight, 0.f);
skel.setBindPosition(shoulder, 0.15f, 0.f, 0.f);
skel.setBindPosition(elbow, upperArm, 0.f, 0.f);
skel.setBindPosition(hand, lowerArm, 0.f, 0.f);
}
float handChestDistance(const AnimSkeleton& skel, const AnimClip& clip, float time) {
AnimPose pose;
clip.sample(time, &pose, &skel);
pose.computeWorld(&skel);
const int chest = skel.findBone("Spine");
const int hand = skel.findBone("LeftHand");
const float dx = pose.getWorldPositionX(hand) - pose.getWorldPositionX(chest);
const float dy = pose.getWorldPositionY(hand) - pose.getWorldPositionY(chest);
const float dz = pose.getWorldPositionZ(hand) - pose.getWorldPositionZ(chest);
return std::sqrt(dx * dx + dy * dy + dz * dz);
}
bool rotationKeysDiffer(const AnimClip& a, const AnimClip& b, int bone) {
if (a.getRotationKeyCount(bone) == 0 || b.getRotationKeyCount(bone) == 0) return false;
const float ax = a.getRotationKeyX(bone, 0);
const float ay = a.getRotationKeyY(bone, 0);
const float az = a.getRotationKeyZ(bone, 0);
const float aw = a.getRotationKeyW(bone, 0);
const float bx = b.getRotationKeyX(bone, 0);
const float by = b.getRotationKeyY(bone, 0);
const float bz = b.getRotationKeyZ(bone, 0);
const float bw = b.getRotationKeyW(bone, 0);
const float dot = std::fabs(ax * bx + ay * by + az * bz + aw * bw);
return dot < 0.999f;
}
} // namespace
TEST_CASE("animation.smr.sensorCloudTagsBodyParts") {
AnimSkeleton skel;
buildArmature(skel, 1.0f, 0.3f, 0.3f);
const AnimSmrSensorCloud cloud = AnimSmrSensorCloud::fromSkeleton(&skel);
CHECK(cloud.getSensorCount() > 0);
bool sawTorso = false;
bool sawArm = false;
for (int i = 0; i < cloud.getSensorCount(); ++i) {
const AnimSmrBodyPart part = cloud.getSensorPart(i);
if (part == AnimSmrBodyPart::Torso) sawTorso = true;
if (part == AnimSmrBodyPart::Arm) sawArm = true;
}
CHECK(sawTorso);
CHECK(sawArm);
}
TEST_CASE("animation.smr.preservesHandTorsoProximityAcrossScale") {
AnimSkeleton source;
AnimSkeleton target;
buildArmature(source, 1.0f, 0.28f, 0.28f);
buildArmature(target, 1.8f, 0.70f, 0.70f);
// Curl the arm so the hand is near the torso on the short source.
AnimClip clip("reach");
clip.setDuration(0.f);
clip.setSampleRate(30.f);
clip.addRotationKey(source.findBone("LeftShoulder"), 0.f, 0.f, 0.f, 0.70710678f, 0.70710678f);
clip.addRotationKey(source.findBone("LeftElbow"), 0.f, 0.f, 0.f, 0.70710678f, 0.70710678f);
const float sourceGap = handChestDistance(source, clip, 0.f);
AnimRetargetProfile fkOnly;
fkOnly.setSkinnedInteractionPreserve(false);
std::unique_ptr<AnimClip> fk(clip.retargetWithProfile(&source, &target, &fkOnly));
const float fkGap = handChestDistance(target, *fk, 0.f);
AnimRetargetProfile smr;
smr.setSkinnedInteractionPreserve(true);
smr.setInteractionContactThreshold(std::max(sourceGap * 1.25f, 0.35f));
smr.setInteractionCorrectionWeight(1.f);
smr.addInteractionIkChain("LeftShoulder", "LeftElbow", "LeftHand");
std::unique_ptr<AnimClip> refined(clip.retargetWithProfile(&source, &target, &smr));
const float smrGap = handChestDistance(target, *refined, 0.f);
CHECK(smr.getInteractionCorrectionCount() >= 1);
CHECK(fkGap > sourceGap);
// Either the world-space gap shrinks, or the IK rewrite clearly changed chain rotations.
const bool gapImproved = smrGap + 1e-4f < fkGap;
const bool keysChanged = rotationKeysDiffer(*fk, *refined, target.findBone("LeftElbow")) ||
rotationKeysDiffer(*fk, *refined, target.findBone("LeftShoulder"));
const bool refinedDiffers = gapImproved || keysChanged;
CHECK(refinedDiffers);
}