diff --git a/docs/usr/modules/editor.md b/docs/usr/modules/editor.md index d0bc8cf5..93ea8244 100644 --- a/docs/usr/modules/editor.md +++ b/docs/usr/modules/editor.md @@ -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` 设置和读取严格为正的倍率;倍率只缩放调用者注入的 diff --git a/examples/combat-action-editor/main.nut b/examples/combat-action-editor/main.nut index 9bde7409..c24dda54 100644 --- a/examples/combat-action-editor/main.nut +++ b/examples/combat-action-editor/main.nut @@ -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()); diff --git a/src/modules/action/editor/ActionTimelineScriptBindings.cpp b/src/modules/action/editor/ActionTimelineScriptBindings.cpp index a7280093..1c1c4609 100644 --- a/src/modules/action/editor/ActionTimelineScriptBindings.cpp +++ b/src/modules/action/editor/ActionTimelineScriptBindings.cpp @@ -728,6 +728,32 @@ public: return coordinator_->clearLayerBoneMask(activeLayer_); } + [[nodiscard]] Result setRuntimeLayerWeight(float weight) { + if (!coordinator_ || !activeMontage()) + return Result::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 setRuntimeLayerAdditive(bool additive) { + if (!coordinator_ || !activeMontage()) + return Result::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 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& 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", diff --git a/src/modules/animation/AnimLayerMixer.cpp b/src/modules/animation/AnimLayerMixer.cpp index 731d14e9..fcbc0f37 100644 --- a/src/modules/animation/AnimLayerMixer.cpp +++ b/src/modules/animation/AnimLayerMixer.cpp @@ -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); } } diff --git a/src/modules/animation/AnimMath.h b/src/modules/animation/AnimMath.h index 3746207a..e6565298 100644 --- a/src/modules/animation/AnimMath.h +++ b/src/modules/animation/AnimMath.h @@ -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); diff --git a/src/modules/animation/MontageCoordinator.cpp b/src/modules/animation/MontageCoordinator.cpp index 01b8351a..c0dee9c6 100644 --- a/src/modules/animation/MontageCoordinator.cpp +++ b/src/modules/animation/MontageCoordinator.cpp @@ -19,13 +19,20 @@ Result 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(skeleton.getBoneCount())) { + skeleton_.applyBindPose(composedPose_.get()); +} void MontageCoordinator::ensureLayer(std::size_t layer) { while (layers_.size() <= layer) { Layer entry; - entry.pose = std::make_unique(skeleton_.getBoneCount()); + entry.pose = std::make_unique(skeleton_.getBoneCount()); + entry.rawPose = std::make_unique(skeleton_.getBoneCount()); + entry.blendScratch = std::make_unique(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> MontageCoordinator::layerBoneMask(std::size_t layer) return Result>::success(layers_[layer].boneMask); } +Result MontageCoordinator::setLayerWeight(std::size_t layer, float weight) { + if (layer > static_cast(std::numeric_limits::max() / 2U)) + return coordinatorError(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer"); + if (!std::isfinite(weight) || weight < 0.0f || weight > 1.0f) + return coordinatorError(DiagnosticCode::InvalidArgument, + "montage layer weight must be finite and within [0,1]", "weight"); + ensureLayer(layer); + if (layers_[layer].weight == weight) return Result::success(Status::success(StatusCode::NoOp)); + layers_[layer].weight = weight; + return Result::success(Status::success(StatusCode::Applied)); +} + +Result MontageCoordinator::layerWeight(std::size_t layer) const { + if (layer >= layers_.size()) + return coordinatorError(DiagnosticCode::NotFound, "montage layer does not exist", "layer"); + return Result::success(layers_[layer].weight); +} + +Result MontageCoordinator::setLayerAdditive(std::size_t layer, bool additive) { + if (layer > static_cast(std::numeric_limits::max() / 2U)) + return coordinatorError(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer"); + ensureLayer(layer); + if (layers_[layer].additive == additive) return Result::success(Status::success(StatusCode::NoOp)); + layers_[layer].additive = additive; + return Result::success(Status::success(StatusCode::Applied)); +} + +Result MontageCoordinator::layerAdditive(std::size_t layer) const { + if (layer >= layers_.size()) + return coordinatorError(DiagnosticCode::NotFound, "montage layer does not exist", "layer"); + return Result::success(layers_[layer].additive); +} + Result MontageCoordinator::setLayerRootMotionReceiver(std::size_t layer, IMontageRootMotionReceiver& receiver) { if (layer > static_cast(std::numeric_limits::max() / 2U)) @@ -195,43 +235,75 @@ Result MontageCoordinator::clearLayerRootMotionReceiver(std::size_t layer) return Result::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(second->player->weight() / total)); + } + } + return total; +} + Result> MontageCoordinator::pose(std::size_t layerIndex) { if (layerIndex >= layers_.size()) return coordinatorError>(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>::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(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(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>::success(std::ref(*layer.pose)); } +Result> MontageCoordinator::compose(const AnimPose& basePose) { + if (basePose.getBoneCount() != skeleton_.getBoneCount()) + return coordinatorError>( + 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(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(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>::success(std::ref(*composedPose_)); +} + void MontageCoordinator::collectFinished() noexcept { for (Layer& layer : layers_) for (Slot& slot : layer.slots) diff --git a/src/modules/animation/MontageCoordinator.h b/src/modules/animation/MontageCoordinator.h index 865af45f..53889429 100644 --- a/src/modules/animation/MontageCoordinator.h +++ b/src/modules/animation/MontageCoordinator.h @@ -65,6 +65,24 @@ public: [[nodiscard]] Result clearLayerBoneMask(std::size_t layer); /** @brief Return an owning copy of one configured layer mask. */ [[nodiscard]] Result> 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 setLayerWeight(std::size_t layer, float weight); + /** @brief Return one configured layer's global influence, or NotFound for an absent layer. */ + [[nodiscard]] Result 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 setLayerAdditive(std::size_t layer, bool additive); + /** @brief Return whether one configured layer uses additive composition, or NotFound for an absent layer. */ + [[nodiscard]] Result 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 clearLayerRootMotionReceiver(std::size_t layer); /** @brief Borrow the normalized full layer pose; invalid layers return a structured failure. */ [[nodiscard]] Result> 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> 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 pose; + std::unique_ptr rawPose; + std::unique_ptr blendScratch; std::vector 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 layers_; + std::unique_ptr composedPose_; }; } // namespace eve::animation diff --git a/test/action_montage.cpp b/test/action_montage.cpp index 0e561905..e0dab9bb 100644 --- a/test/action_montage.cpp +++ b/test/action_montage.cpp @@ -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::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"); diff --git a/test/editor_action_timeline_script.cpp b/test/editor_action_timeline_script.cpp index ae02750d..85f50560 100644 --- a/test/editor_action_timeline_script.cpp +++ b/test/editor_action_timeline_script.cpp @@ -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();