feat(animation): compose weighted montage layers

This commit is contained in:
西风逍遥游 committed 2026-09-13 15:37:27 +08:00
1 parent bda86ca064
commit b2b24a82eb
9 files changed
+299 -58

No files matched your search

+4
View File
@@ -686,6 +686,10 @@ Montage 运行时副本;Undo/Redo、`jumpRuntimeSection`、`evaluateRuntimeSec
`setRuntimeBoneMask` 会同步复制一个与预览骨架匹配的 `AnimBoneMask` 到当前层,调用方随后可以安全销毁或修改
原 Mask;`clearRuntimeBoneMask` 恢复全骨骼影响。示例的 Montage Inspector 可从当前选中关节生成 Mask,选择是否
包含子关节,并提供紧凑的 Apply/Clear 工具栏。
`setRuntimeLayerWeight/getRuntimeLayerWeight` 和 `setRuntimeLayerAdditive/getRuntimeLayerAdditive` 配置当前协调器层,
不会写入 ActionTimeline 资产。`getRuntimePoseOverBase(basePose)` 以调用期间借用的基础姿态为底,按层索引依次应用
归一化双槽、Layer Weight、Bone Mask 与 bind-pose-relative Additive,返回由编辑器协调器拥有、下次合成前有效的
最终姿态;基础姿态骨骼数不匹配或运行时尚未启动时返回 `null`。
`getRuntimeState/getRuntimeElapsedSeconds/getRuntimeDurationSeconds/getRuntimeLayer/getRuntimeSlot` 提供只读句柄诊断;
示例把它们投影到已有状态行,不增加窗口或面板尺寸。
运行中的预览还可用 `setRuntimeRate/getRuntimeRate` 设置和读取严格为正的倍率;倍率只缩放调用者注入的
+23 -4
View File
@@ -12,7 +12,7 @@ persist combatEditor = {
insertTrack = 0, insertNotifyType = 0, insertStateType = 2,
selectedTrack = 0, selectedSplit = 0, newTrackOpen = false, newTrackKind = 0, nextTrackId = 0,
clipEditor = null, boneMouseDown = false,
clip = null, player = null, knight = null, knightParts = [], skins = [],
clip = null, player = null, basePlayer = null, knight = null, knightParts = [], skins = [],
enemySkeleton = null, enemyPlayer = null, enemyParts = [], enemySkins = [],
combatRuntime = null, combatTick = 0,
abilityGrantId = 0, abilityActivationId = 0, abilityPhase = "ready", abilityCooldown = 0.0,
@@ -38,7 +38,8 @@ local editorUiDefaults = {
insertPanelOpen=false, newSectionOpen=false, nextSectionId=0,
insertTrack=0, insertNotifyType=0, insertStateType=2,
selectedTrack=0, selectedSplit=0, newTrackOpen=false, newTrackKind=0, nextTrackId=0,
enemySkeleton=null, enemyPlayer=null, enemyParts=[], enemySkins=[], combatRuntime=null, combatTick=0,
basePlayer=null, enemySkeleton=null, enemyPlayer=null, enemyParts=[], enemySkins=[], combatRuntime=null,
combatTick=0,
abilityGrantId=0, abilityActivationId=0, abilityPhase="ready", abilityCooldown=0.0,
comboWindowOpen=false, comboInputTag="", attackWas=false, comboCount=0,
playbackRate=1.0, slowWas=false, normalWas=false, fastWas=false,
@@ -235,6 +236,13 @@ function buildCharacterPreview() {
combatEditor.player.play(combatEditor.clip);
combatEditor.player.setLoop(false);
local playerIdleIndex = findAnimation(combatEditor.generalLibrary, "Idle_A");
if (playerIdleIndex < 0) throw "KayKit idle clip Idle_A was not found";
local playerIdle = anim.newClipFromModel(combatEditor.generalLibrary, combatEditor.skeleton, playerIdleIndex);
playerIdle.setLoop(true);
combatEditor.basePlayer = anim.newPlayer(combatEditor.skeleton);
combatEditor.basePlayer.play(playerIdle); combatEditor.basePlayer.setLoop(true);
combatEditor.enemySkeleton = anim.newSkeletonFromModel(combatEditor.knight);
local idleIndex = findAnimation(combatEditor.generalLibrary, "Idle_A");
if (idleIndex < 0) throw "KayKit idle clip Idle_A was not found";
@@ -644,6 +652,8 @@ function panelInspector() {
ui.checkbox("Vertical", true, "montage-root-vertical");
ui.checkbox("Rotation", true, "montage-root-rotation");
ui.text("Layer Bone Mask", "montage-mask-title");
ui.slider("Layer Weight", 1.0, 0.0, 1.0, "montage-runtime-weight");
ui.checkbox("Additive", false, "montage-runtime-additive");
ui.slider("Selected Joint", 1.0, 0.0, 1.0, "montage-mask-weight");
ui.checkbox("Include Children", true, "montage-mask-children");
ui.beginToolbar("montage-mask-tools");
@@ -1203,8 +1213,14 @@ function handleUiEvents() {
result = combatEditor.timeline.setSectionSplit(combatEditor.selectedSplit, splitTime);
if (result.ok) combatEditor.selectedSplit = sectionSplitIndexAt(splitTime);
combatEditor.status = result.ok ? "Physical section split moved" : result.status.summary;
} else if (id == "montage-runtime-weight") {
result = combatEditor.timeline.setRuntimeLayerWeight(ui.getValue(id));
combatEditor.status = result.ok ? "Runtime layer weight updated" : result.status.summary;
} else if (id == "montage-runtime-additive") {
result = combatEditor.timeline.setRuntimeLayerAdditive(ui.getChecked(id));
combatEditor.status = result.ok ? "Runtime layer mode updated" : result.status.summary;
} else if (id.find("montage-") == 0 && id != "montage-tools" &&
id.find("montage-mask-") != 0) {
id.find("montage-mask-") != 0 && id.find("montage-runtime-") != 0) {
result = commitMontageSettings();
combatEditor.status = result.ok ? "Montage settings committed" : result.status.summary;
} else if (id == "insert-track") combatEditor.insertTrack = ui.getValue("insert-track").tointeger();
@@ -1440,7 +1456,8 @@ function updateKeyboardShortcuts() {
}
function updatePose(dt) {
local pose = combatEditor.timeline.getRuntimePose();
combatEditor.basePlayer.update(dt);
local pose = combatEditor.timeline.getRuntimePoseOverBase(combatEditor.basePlayer.getPose());
if (pose == null) {
combatEditor.player.setTime(combatEditor.clipEditor.getPlayhead());
pose = combatEditor.player.getPose();
@@ -1605,6 +1622,8 @@ function updateLabels() {
ui.setChecked("montage-root-horizontal", combatEditor.timeline.getMontageRootMotionHorizontal());
ui.setChecked("montage-root-vertical", combatEditor.timeline.getMontageRootMotionVertical());
ui.setChecked("montage-root-rotation", combatEditor.timeline.getMontageRootMotionRotation());
ui.setValue("montage-runtime-weight", combatEditor.timeline.getRuntimeLayerWeight());
ui.setChecked("montage-runtime-additive", combatEditor.timeline.getRuntimeLayerAdditive());
local splitCount = combatEditor.timeline.getSectionSplitCount();
combatEditor.selectedSplit = splitCount > 0 ? clampf(combatEditor.selectedSplit, 0, splitCount - 1).tointeger() : 0;
ui.setValue("section-split-selector", combatEditor.selectedSplit.tofloat());
@@ -728,6 +728,32 @@ public:
return coordinator_->clearLayerBoneMask(activeLayer_);
}
[[nodiscard]] Result<void> setRuntimeLayerWeight(float weight) {
if (!coordinator_ || !activeMontage())
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
return coordinator_->setLayerWeight(activeLayer_, weight);
}
[[nodiscard]] float runtimeLayerWeight() const {
if (!coordinator_ || !activeMontage()) return 0.0f;
auto weight = coordinator_->layerWeight(activeLayer_);
return weight ? weight.value() : 0.0f;
}
[[nodiscard]] Result<void> setRuntimeLayerAdditive(bool additive) {
if (!coordinator_ || !activeMontage())
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
return coordinator_->setLayerAdditive(activeLayer_, additive);
}
[[nodiscard]] bool runtimeLayerAdditive() const {
if (!coordinator_ || !activeMontage()) return false;
auto additive = coordinator_->layerAdditive(activeLayer_);
return additive ? additive.value() : false;
}
[[nodiscard]] Result<animation::MontageAdvance> beginRuntimeBlendOut(Duration duration) {
auto* montage = activeMontage();
if (!montage)
@@ -774,6 +800,11 @@ public:
auto pose = coordinator_->pose(activeLayer_);
return pose ? &pose.value().get() : nullptr;
}
[[nodiscard]] animation::AnimPose* runtimePoseOverBase(const animation::AnimPose& basePose) {
if (!coordinator_ || !montageHandle_.isValid()) return nullptr;
auto pose = coordinator_->compose(basePose);
return pose ? &pose.value().get() : nullptr;
}
[[nodiscard]] const std::optional<animation::MontageAdvance>& runtimeAdvance() const noexcept {
return runtimeAdvance_;
}
@@ -1515,6 +1546,20 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
"action timeline editor must not be null");
return script::projectResult(vm, self->clearRuntimeBoneMask());
});
actionEditor.addFunc("setRuntimeLayerWeight", [vm](ScriptActionTimelineEditor* self, float weight) {
if (!self)
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
return script::projectResult(vm, self->setRuntimeLayerWeight(weight));
});
actionEditor.addFunc("getRuntimeLayerWeight",
[](ScriptActionTimelineEditor* self) { return self ? self->runtimeLayerWeight() : 0.0f; });
actionEditor.addFunc("setRuntimeLayerAdditive", [vm](ScriptActionTimelineEditor* self, bool additive) {
if (!self)
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
return script::projectResult(vm, self->setRuntimeLayerAdditive(additive));
});
actionEditor.addFunc("getRuntimeLayerAdditive",
[](ScriptActionTimelineEditor* self) { return self && self->runtimeLayerAdditive(); });
actionEditor.addFunc("beginRuntimeBlendOut", [vm](ScriptActionTimelineEditor* self, float durationSeconds) {
if (!self)
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
@@ -1551,6 +1596,9 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
});
actionEditor.addFunc("getRuntimePose",
[](ScriptActionTimelineEditor* self) { return self ? self->runtimePose() : nullptr; });
actionEditor.addFunc("getRuntimePoseOverBase", [](ScriptActionTimelineEditor* self, animation::AnimPose* basePose) {
return self && basePose ? self->runtimePoseOverBase(*basePose) : nullptr;
});
actionEditor.addFunc("isRuntimePlaying",
[](ScriptActionTimelineEditor* self) { return self && self->runtimePlaying(); });
actionEditor.addFunc("getRuntimeState",
+1 -30
View File
@@ -16,14 +16,6 @@ float layerBoneWeight(const AnimBoneMask* mask, float layerWeight, int boneIndex
return clampf(layerWeight * maskWeight, 0.f, 1.f);
}
void multiplyQuat(float ax, float ay, float az, float aw, float bx, float by, float bz, float bw, float& ox, float& oy,
float& oz, float& ow) {
ow = aw * bw - ax * bx - ay * by - az * bz;
ox = aw * bx + ax * bw + ay * bz - az * by;
oy = aw * by - ax * bz + ay * bw + az * bx;
oz = aw * bz + ax * by - ay * bx + az * bw;
}
} // namespace
AnimBoneMask::AnimBoneMask(AnimSkeleton* skeleton) : skeleton_(skeleton) {
@@ -139,28 +131,7 @@ void AnimLayerMixer::applyAdditive(const Layer& layer) {
for (int bone = 0; bone < pose_.getBoneCount(); ++bone) {
const float weight = layerBoneWeight(layer.mask, layer.weight, bone);
if (weight <= 0.f) continue;
TransformTRS& base = pose_.local(bone);
const TransformTRS& sample = layerPose->local(bone);
const TransformTRS& reference = skeleton_->bindLocal(bone);
base.px += (sample.px - reference.px) * weight;
base.py += (sample.py - reference.py) * weight;
base.pz += (sample.pz - reference.pz) * weight;
base.sx *= lerpf(1.f, std::fabs(reference.sx) > 1e-8f ? sample.sx / reference.sx : 1.f, weight);
base.sy *= lerpf(1.f, std::fabs(reference.sy) > 1e-8f ? sample.sy / reference.sy : 1.f, weight);
base.sz *= lerpf(1.f, std::fabs(reference.sz) > 1e-8f ? sample.sz / reference.sz : 1.f, weight);
float dx, dy, dz, dw;
multiplyQuat(sample.qx, sample.qy, sample.qz, sample.qw, -reference.qx, -reference.qy, -reference.qz,
reference.qw, dx, dy, dz, dw);
float ax, ay, az, aw;
slerpQuat(0.f, 0.f, 0.f, 1.f, dx, dy, dz, dw, weight, ax, ay, az, aw);
float qx, qy, qz, qw;
multiplyQuat(base.qx, base.qy, base.qz, base.qw, ax, ay, az, aw, qx, qy, qz, qw);
base.qx = qx;
base.qy = qy;
base.qz = qz;
base.qw = qw;
base.normalizeRotation();
applyAdditiveTRS(pose_.local(bone), layerPose->local(bone), skeleton_->bindLocal(bone), weight);
}
}
+38
View File
@@ -82,6 +82,44 @@ inline TransformTRS blendTRS(const TransformTRS &a, const TransformTRS &b, float
return out;
}
/**
* @brief Apply a bind-pose-relative additive sample to an accumulated local transform.
* @param base Accumulated transform mutated in place.
* @param sample Evaluated additive-layer transform.
* @param reference Bind-pose transform used as the additive identity.
* @param weight Finite blend influence, clamped to [0,1].
*/
inline void applyAdditiveTRS(TransformTRS &base, const TransformTRS &sample, const TransformTRS &reference,
float weight) {
weight = clampf(weight, 0.f, 1.f);
base.px += (sample.px - reference.px) * weight;
base.py += (sample.py - reference.py) * weight;
base.pz += (sample.pz - reference.pz) * weight;
base.sx *= lerpf(1.f, std::fabs(reference.sx) > 1e-8f ? sample.sx / reference.sx : 1.f, weight);
base.sy *= lerpf(1.f, std::fabs(reference.sy) > 1e-8f ? sample.sy / reference.sy : 1.f, weight);
base.sz *= lerpf(1.f, std::fabs(reference.sz) > 1e-8f ? sample.sz / reference.sz : 1.f, weight);
const auto multiplyQuat = [](float ax, float ay, float az, float aw, float bx, float by, float bz, float bw,
float &ox, float &oy, float &oz, float &ow) {
ow = aw * bw - ax * bx - ay * by - az * bz;
ox = aw * bx + ax * bw + ay * bz - az * by;
oy = aw * by - ax * bz + ay * bw + az * bx;
oz = aw * bz + ax * by - ay * bx + az * bw;
};
float dx, dy, dz, dw;
multiplyQuat(sample.qx, sample.qy, sample.qz, sample.qw, -reference.qx, -reference.qy, -reference.qz, reference.qw,
dx, dy, dz, dw);
float ax, ay, az, aw;
slerpQuat(0.f, 0.f, 0.f, 1.f, dx, dy, dz, dw, weight, ax, ay, az, aw);
float qx, qy, qz, qw;
multiplyQuat(base.qx, base.qy, base.qz, base.qw, ax, ay, az, aw, qx, qy, qz, qw);
base.qx = qx;
base.qy = qy;
base.qz = qz;
base.qw = qw;
base.normalizeRotation();
}
/** @brief Rotate unit +Z by yaw (radians) around Y — used for planar facing. */
inline void yawToForward(float yaw, float &fx, float &fz) {
fx = std::sin(yaw);
+96 -24
View File
@@ -19,13 +19,20 @@ Result<T> coordinatorError(DiagnosticCode code, std::string message, std::string
} // namespace
MontageCoordinator::MontageCoordinator(AnimSkeleton& skeleton) : skeleton_(skeleton) {}
MontageCoordinator::MontageCoordinator(AnimSkeleton& skeleton)
: skeleton_(skeleton), composedPose_(std::make_unique<AnimPose>(skeleton.getBoneCount())) {
skeleton_.applyBindPose(composedPose_.get());
}
void MontageCoordinator::ensureLayer(std::size_t layer) {
while (layers_.size() <= layer) {
Layer entry;
entry.pose = std::make_unique<AnimPose>(skeleton_.getBoneCount());
entry.pose = std::make_unique<AnimPose>(skeleton_.getBoneCount());
entry.rawPose = std::make_unique<AnimPose>(skeleton_.getBoneCount());
entry.blendScratch = std::make_unique<AnimPose>(skeleton_.getBoneCount());
skeleton_.applyBindPose(entry.pose.get());
skeleton_.applyBindPose(entry.rawPose.get());
skeleton_.applyBindPose(entry.blendScratch.get());
layers_.push_back(std::move(entry));
}
}
@@ -172,6 +179,39 @@ Result<std::vector<float>> MontageCoordinator::layerBoneMask(std::size_t layer)
return Result<std::vector<float>>::success(layers_[layer].boneMask);
}
Result<void> MontageCoordinator::setLayerWeight(std::size_t layer, float weight) {
if (layer > static_cast<std::size_t>(std::numeric_limits<MontageHandle::index_type>::max() / 2U))
return coordinatorError<void>(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer");
if (!std::isfinite(weight) || weight < 0.0f || weight > 1.0f)
return coordinatorError<void>(DiagnosticCode::InvalidArgument,
"montage layer weight must be finite and within [0,1]", "weight");
ensureLayer(layer);
if (layers_[layer].weight == weight) return Result<void>::success(Status::success(StatusCode::NoOp));
layers_[layer].weight = weight;
return Result<void>::success(Status::success(StatusCode::Applied));
}
Result<float> MontageCoordinator::layerWeight(std::size_t layer) const {
if (layer >= layers_.size())
return coordinatorError<float>(DiagnosticCode::NotFound, "montage layer does not exist", "layer");
return Result<float>::success(layers_[layer].weight);
}
Result<void> MontageCoordinator::setLayerAdditive(std::size_t layer, bool additive) {
if (layer > static_cast<std::size_t>(std::numeric_limits<MontageHandle::index_type>::max() / 2U))
return coordinatorError<void>(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer");
ensureLayer(layer);
if (layers_[layer].additive == additive) return Result<void>::success(Status::success(StatusCode::NoOp));
layers_[layer].additive = additive;
return Result<void>::success(Status::success(StatusCode::Applied));
}
Result<bool> MontageCoordinator::layerAdditive(std::size_t layer) const {
if (layer >= layers_.size())
return coordinatorError<bool>(DiagnosticCode::NotFound, "montage layer does not exist", "layer");
return Result<bool>::success(layers_[layer].additive);
}
Result<void> MontageCoordinator::setLayerRootMotionReceiver(std::size_t layer,
IMontageRootMotionReceiver& receiver) {
if (layer > static_cast<std::size_t>(std::numeric_limits<MontageHandle::index_type>::max() / 2U))
@@ -195,43 +235,75 @@ Result<void> MontageCoordinator::clearLayerRootMotionReceiver(std::size_t layer)
return Result<void>::success(Status::success(StatusCode::Applied));
}
double MontageCoordinator::evaluateLayerPose(Layer& layer) const {
const Slot* first = nullptr;
const Slot* second = nullptr;
double total = 0.0;
for (const Slot& slot : layer.slots) {
if (!slot.player || slot.player->weight() <= 0.0) continue;
total += slot.player->weight();
if (!first) first = &slot;
else second = &slot;
}
if (!first) {
skeleton_.applyBindPose(layer.rawPose.get());
return 0.0;
}
layer.rawPose->copyFrom(&first->player->pose());
if (second) {
if (total > 0.0) {
layer.blendScratch->copyFrom(layer.rawPose.get());
layer.rawPose->blendFrom(layer.blendScratch.get(), &second->player->pose(),
static_cast<float>(second->player->weight() / total));
}
}
return total;
}
Result<std::reference_wrapper<AnimPose>> MontageCoordinator::pose(std::size_t layerIndex) {
if (layerIndex >= layers_.size())
return coordinatorError<std::reference_wrapper<AnimPose>>(DiagnosticCode::NotFound,
"montage layer does not exist", "layer");
Layer& layer = layers_[layerIndex];
(void)evaluateLayerPose(layer);
skeleton_.applyBindPose(layer.pose.get());
const Slot* first = nullptr;
const Slot* second = nullptr;
for (const Slot& slot : layer.slots) {
if (!slot.player || slot.player->weight() <= 0.0) continue;
if (!first) first = &slot;
else second = &slot;
}
if (!first) return Result<std::reference_wrapper<AnimPose>>::success(std::ref(*layer.pose));
AnimPose composed;
composed.copyFrom(&first->player->pose());
if (second) {
const double total = first->player->weight() + second->player->weight();
if (total > 0.0) {
AnimPose base;
base.copyFrom(&composed);
composed.blendFrom(&base, &second->player->pose(),
static_cast<float>(second->player->weight() / total));
}
}
if (layer.boneMask.empty()) {
layer.pose->copyFrom(&composed);
layer.pose->copyFrom(layer.rawPose.get());
} else {
for (int bone = 0; bone < layer.pose->getBoneCount(); ++bone) {
const float weight = layer.boneMask[static_cast<std::size_t>(bone)];
layer.pose->local(bone) = blendTRS(layer.pose->local(bone), composed.local(bone), weight);
layer.pose->local(bone) = blendTRS(layer.pose->local(bone), layer.rawPose->local(bone), weight);
}
}
return Result<std::reference_wrapper<AnimPose>>::success(std::ref(*layer.pose));
}
Result<std::reference_wrapper<AnimPose>> MontageCoordinator::compose(const AnimPose& basePose) {
if (basePose.getBoneCount() != skeleton_.getBoneCount())
return coordinatorError<std::reference_wrapper<AnimPose>>(
DiagnosticCode::InvalidArgument, "montage base pose must match the coordinator skeleton", "basePose");
composedPose_->copyFrom(&basePose);
for (Layer& layer : layers_) {
const double rawWeight = evaluateLayerPose(layer);
const float layerWeight = clampf(static_cast<float>(rawWeight), 0.0f, 1.0f) * layer.weight;
if (layerWeight <= 0.0f) continue;
for (int bone = 0; bone < composedPose_->getBoneCount(); ++bone) {
const float maskWeight = layer.boneMask.empty() ? 1.0f : layer.boneMask[static_cast<std::size_t>(bone)];
const float weight = clampf(layerWeight * maskWeight, 0.0f, 1.0f);
if (weight <= 0.0f) continue;
if (layer.additive) {
applyAdditiveTRS(composedPose_->local(bone), layer.rawPose->local(bone), skeleton_.bindLocal(bone),
weight);
} else {
composedPose_->local(bone) = blendTRS(composedPose_->local(bone), layer.rawPose->local(bone), weight);
}
}
}
composedPose_->computeWorld(&skeleton_);
return Result<std::reference_wrapper<AnimPose>>::success(std::ref(*composedPose_));
}
void MontageCoordinator::collectFinished() noexcept {
for (Layer& layer : layers_)
for (Slot& slot : layer.slots)
@@ -65,6 +65,24 @@ public:
[[nodiscard]] Result<void> clearLayerBoneMask(std::size_t layer);
/** @brief Return an owning copy of one configured layer mask. */
[[nodiscard]] Result<std::vector<float>> layerBoneMask(std::size_t layer) const;
/**
* @brief Set one layer's finite global influence.
* @param layer Layer index, created on demand.
* @param weight Influence in [0,1].
* @return Applied, NoOp, or InvalidArgument without partially changing configuration.
*/
[[nodiscard]] Result<void> setLayerWeight(std::size_t layer, float weight);
/** @brief Return one configured layer's global influence, or NotFound for an absent layer. */
[[nodiscard]] Result<float> layerWeight(std::size_t layer) const;
/**
* @brief Select bind-pose-relative additive or ordered override composition for one layer.
* @param layer Layer index, created on demand.
* @param additive True for additive; false for override.
* @return Applied or NoOp, or InvalidArgument for an unrepresentable layer index.
*/
[[nodiscard]] Result<void> setLayerAdditive(std::size_t layer, bool additive);
/** @brief Return whether one configured layer uses additive composition, or NotFound for an absent layer. */
[[nodiscard]] Result<bool> layerAdditive(std::size_t layer) const;
/**
* @brief Install one root-motion receiver shared by current and future slots of a layer.
* @param layer Layer index.
@@ -77,6 +95,15 @@ public:
[[nodiscard]] Result<void> clearLayerRootMotionReceiver(std::size_t layer);
/** @brief Borrow the normalized full layer pose; invalid layers return a structured failure. */
[[nodiscard]] Result<std::reference_wrapper<AnimPose>> pose(std::size_t layer);
/**
* @brief Compose every configured montage layer over an immediate borrowed base pose.
* @param basePose Base pose for this synchronous evaluation; it is never retained and must match the skeleton.
* @return Borrowed coordinator-owned pose, valid until the next composition or coordinator destruction.
* @details Layers are applied in ascending index order. Slot weights are normalized within each layer; their
* clamped sum, the layer weight, and the optional bone mask determine top-level influence. Additive layers use
* bind-pose-relative deltas. Owner-thread-only, deterministic for identical poses and weights, and non-reentrant.
*/
[[nodiscard]] Result<std::reference_wrapper<AnimPose>> compose(const AnimPose& basePose);
/** @brief Reclaim finished slots and invalidate their handles. */
void collectFinished() noexcept;
@@ -90,7 +117,11 @@ private:
Slot slots[2];
std::size_t active = 1;
std::unique_ptr<AnimPose> pose;
std::unique_ptr<AnimPose> rawPose;
std::unique_ptr<AnimPose> blendScratch;
std::vector<float> boneMask;
float weight = 1.0f;
bool additive = false;
IMontageRootMotionReceiver* rootMotionReceiver = nullptr;
};
@@ -103,9 +134,11 @@ private:
[[nodiscard]] const Slot* slotFor(MontageHandle handle) const noexcept;
void ensureLayer(std::size_t layer);
void retireSlot(Slot& slot) noexcept;
[[nodiscard]] double evaluateLayerPose(Layer& layer) const;
AnimSkeleton& skeleton_;
std::vector<Layer> layers_;
std::unique_ptr<AnimPose> composedPose_;
};
} // namespace eve::animation
+50
View File
@@ -496,6 +496,56 @@ TEST_CASE("actionMontage.coordinatorLayerMaskRestrictsCompositionPerBone") {
CHECK(std::fabs(unmasked.value().get().local(1).py - 2.0f) < 1e-5f);
}
TEST_CASE("actionMontage.coordinatorComposesWeightedOverrideAndAdditiveLayersOverBasePose") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
skeleton.addBone("upper", 0);
skeleton.setBindPosition(1, 0.0f, 1.0f, 0.0f);
eve::animation::MontageCoordinator coordinator(skeleton);
CHECK(!coordinator.setLayerWeight(0, -0.1f).ok());
CHECK(!coordinator.setLayerWeight(0, std::numeric_limits<float>::infinity()).ok());
REQUIRE(coordinator.setLayerWeight(0, 0.5f).ok());
REQUIRE(coordinator.setLayerBoneMask(0, {0.0f, 1.0f}).ok());
auto overrideHandle = coordinator.play(0, montageTimeline(), maskedMontageClips(),
eve::action::ActionExecutionId(51), eve::SimulationTick(1));
REQUIRE(overrideHandle.ok());
eve::action::ActionAdvance overrideAdvance;
overrideAdvance.id = eve::action::ActionExecutionId(51);
overrideAdvance.phase = eve::action::ActionPhase::Active;
overrideAdvance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(coordinator.present(overrideHandle.value(), overrideAdvance, eve::SimulationTick(2)).ok());
REQUIRE(coordinator.setLayerWeight(1, 0.5f).ok());
REQUIRE(coordinator.setLayerAdditive(1, true).ok());
auto additiveHandle = coordinator.play(1, montageTimeline(), montageClips(), eve::action::ActionExecutionId(52),
eve::SimulationTick(3));
REQUIRE(additiveHandle.ok());
eve::action::ActionAdvance additiveAdvance;
additiveAdvance.id = eve::action::ActionExecutionId(52);
additiveAdvance.phase = eve::action::ActionPhase::Active;
additiveAdvance.totalElapsed = eve::Duration::fromSeconds(0.5).takeValue();
REQUIRE(coordinator.present(additiveHandle.value(), additiveAdvance, eve::SimulationTick(4)).ok());
eve::animation::AnimPose base(skeleton.getBoneCount());
skeleton.applyBindPose(&base);
base.setLocalPosition(0, 10.0f, 0.0f, 0.0f);
base.setLocalPosition(1, 0.0f, 10.0f, 0.0f);
auto composed = coordinator.compose(base);
REQUIRE(composed.ok());
CHECK(std::fabs(composed.value().get().local(0).px - 10.25f) < 1e-5f);
CHECK(std::fabs(composed.value().get().local(1).py - 6.0f) < 1e-5f);
auto weight = coordinator.layerWeight(0);
REQUIRE(weight.ok());
CHECK_EQ(weight.value(), 0.5f);
auto additive = coordinator.layerAdditive(1);
REQUIRE(additive.ok());
CHECK(additive.value());
eve::animation::AnimPose foreignBase(1);
CHECK(!coordinator.compose(foreignBase).ok());
}
TEST_CASE("actionMontage.coordinatorRootMotionReceiverFollowsLayerSlots") {
eve::animation::AnimSkeleton skeleton;
skeleton.addBone("root");
+6
View File
@@ -44,8 +44,14 @@ TEST_CASE("editor.actionTimeline.scriptUsesCanonicalTransactionsAndWorkspace") {
testAnimation <- eve.Animation();
testSkeleton <- testAnimation.newSkeleton();
testBoneMask <- testAnimation.newBoneMask(testSkeleton);
testPose <- testAnimation.newPose(testSkeleton.getBoneCount());
setBoneMaskBeforeStart <- actionEditor.setRuntimeBoneMask(testBoneMask);
clearBoneMaskBeforeStart <- actionEditor.clearRuntimeBoneMask();
setLayerWeightBeforeStart <- actionEditor.setRuntimeLayerWeight(0.5);
setLayerAdditiveBeforeStart <- actionEditor.setRuntimeLayerAdditive(true);
runtimeLayerWeightBeforeStart <- actionEditor.getRuntimeLayerWeight();
runtimeLayerAdditiveBeforeStart <- actionEditor.getRuntimeLayerAdditive();
runtimePoseOverBaseBeforeStart <- actionEditor.getRuntimePoseOverBase(testPose);
runtimeStateBeforeStart <- actionEditor.getRuntimeState();
runtimeElapsedBeforeStart <- actionEditor.getRuntimeElapsedSeconds();
runtimeDurationBeforeStart <- actionEditor.getRuntimeDurationSeconds();