feat(editor): author montage layer bone masks

This commit is contained in:
西风逍遥游 committed 2026-09-13 14:48:16 +08:00
1 parent 76c7b07f4e
commit 45e4aedabb
5 files changed
+68 -1

No files matched your search

+3
View File
@@ -683,6 +683,9 @@ Montage 运行时副本;Undo/Redo、`jumpRuntimeSection`、`evaluateRuntimeSec
`syncRuntimeSection`、`syncRuntimeSectionAndJump`、`clearRuntimeSectionSync` 和磁吸目标因此共享一份边界事实。
归一化求值把 `[0,1]` 映射到物理 Section;同步倍率可以只配置,也可以配置后立即跳到该段起点,并可明确清除。
运行时按资产的 `animationLayer` 选择协调器层,视口读取该层的双槽归一化合成姿态。
`setRuntimeBoneMask` 会同步复制一个与预览骨架匹配的 `AnimBoneMask` 到当前层,调用方随后可以安全销毁或修改
原 Mask;`clearRuntimeBoneMask` 恢复全骨骼影响。示例的 Montage Inspector 可从当前选中关节生成 Mask,选择是否
包含子关节,并提供紧凑的 Apply/Clear 工具栏。
运行中的预览还可用 `setRuntimeRate/getRuntimeRate` 设置和读取严格为正的倍率;倍率只缩放调用者注入的
delta,不读取墙钟,也不写回资产的 `basePlayRate`。`getRuntimePhysicalSection`、
`getRuntimeSectionProgress` 和 `getRuntimeWeight` 可直接驱动运行诊断 HUD。
+26 -1
View File
@@ -643,6 +643,13 @@ function panelInspector() {
ui.checkbox("Horizontal", true, "montage-root-horizontal");
ui.checkbox("Vertical", true, "montage-root-vertical");
ui.checkbox("Rotation", true, "montage-root-rotation");
ui.text("Layer Bone Mask", "montage-mask-title");
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");
ui.iconButton("save", "Apply", "apply-montage-mask");
ui.iconButton("close", "Clear", "clear-montage-mask");
ui.end();
ui.text("Physical Sections", "physical-sections-title");
ui.combo("Split", sectionSplitChoices(), combatEditor.selectedSplit, "section-split-selector");
ui.slider("Split Time", 0.5, 0.0, combatEditor.timeline.getDuration(), "section-split-time");
@@ -1090,6 +1097,23 @@ function handleUiEvents() {
applyHistory(id == "track-undo" ? "undo" : "redo");
} else if (id == "montage-undo" || id == "montage-redo") {
applyHistory(id == "montage-undo" ? "undo" : "redo");
} else if (id == "apply-montage-mask") {
ui.select("action.inspector");
local mask = anim.newBoneMask(combatEditor.skeleton);
mask.setAll(0.0);
local bone = combatEditor.clipEditor.getSelectedBone();
local applied = ui.getChecked("montage-mask-children") ?
mask.setBoneAndChildren(bone, ui.getValue("montage-mask-weight")) :
mask.setBoneWeightByName(bone, ui.getValue("montage-mask-weight"));
if (!applied) combatEditor.status = "Bone mask joint was not found · " + bone;
else {
local result = combatEditor.timeline.setRuntimeBoneMask(mask);
combatEditor.status = result.ok ? "Layer mask applied from joint · " + bone
: result.status.summary;
}
} else if (id == "clear-montage-mask") {
local result = combatEditor.timeline.clearRuntimeBoneMask();
combatEditor.status = result.ok ? "Layer bone mask cleared" : result.status.summary;
} else if (id == "add-section-split") {
local splitTime = combatEditor.timeline.getPreviewTime();
local result = combatEditor.timeline.addSectionSplit(splitTime);
@@ -1179,7 +1203,8 @@ 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.find("montage-") == 0 && id != "montage-tools") {
} else if (id.find("montage-") == 0 && id != "montage-tools" &&
id.find("montage-mask-") != 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();
@@ -10,6 +10,7 @@
#include "action/editor/ActionTimelineWidget.h"
#include "animation/AnimClip.h"
#include "animation/AnimImporter.h"
#include "animation/AnimLayerMixer.h"
#include "animation/AnimPose.h"
#include "animation/MontageCoordinator.h"
#include "animation/MontagePlayer.h"
@@ -713,6 +714,20 @@ public:
: StatusCode::NoOp));
}
[[nodiscard]] Result<void> setRuntimeBoneMask(const animation::AnimBoneMask& mask) {
if (!coordinator_ || !activeMontage())
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
return coordinator_->setLayerBoneMask(activeLayer_, mask);
}
[[nodiscard]] Result<void> clearRuntimeBoneMask() {
if (!coordinator_ || !activeMontage())
return Result<void>::failure(
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
return coordinator_->clearLayerBoneMask(activeLayer_);
}
[[nodiscard]] Result<animation::MontageAdvance> beginRuntimeBlendOut(Duration duration) {
auto* montage = activeMontage();
if (!montage)
@@ -1459,6 +1474,19 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
return script::projectResult(vm,
self->clearRuntimeSectionSync(static_cast<std::size_t>(sectionIndex)));
});
actionEditor.addFunc("setRuntimeBoneMask",
[vm](ScriptActionTimelineEditor* self, animation::AnimBoneMask* mask) {
if (!self || !mask)
return bindingFailure(vm, DiagnosticCode::InvalidArgument,
"action timeline editor and bone mask must not be null", "mask");
return script::projectResult(vm, self->setRuntimeBoneMask(*mask));
});
actionEditor.addFunc("clearRuntimeBoneMask", [vm](ScriptActionTimelineEditor* self) {
if (!self)
return bindingFailure(vm, DiagnosticCode::InvalidArgument,
"action timeline editor must not be null");
return script::projectResult(vm, self->clearRuntimeBoneMask());
});
actionEditor.addFunc("beginRuntimeBlendOut", [vm](ScriptActionTimelineEditor* self, float durationSeconds) {
if (!self)
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
+4
View File
@@ -430,6 +430,10 @@ TEST_CASE("actionMontage.coordinatorLayerMaskRestrictsCompositionPerBone") {
REQUIRE(configured.ok());
REQUIRE_EQ(configured.value().size(), 2U);
CHECK_EQ(configured.value()[1], 0.5f);
upperBody.setAll(0.0f);
configured = coordinator.layerBoneMask(0);
REQUIRE(configured.ok());
CHECK_EQ(configured.value()[1], 0.5f);
auto handle = coordinator.play(0, montageTimeline(), maskedMontageClips(),
eve::action::ActionExecutionId(21), eve::SimulationTick(1));
+7
View File
@@ -41,6 +41,11 @@ TEST_CASE("editor.actionTimeline.scriptUsesCanonicalTransactionsAndWorkspace") {
sectionProgressBeforeStart <- actionEditor.evaluateRuntimeSectionProgress(0, 0.5);
sectionSyncJumpBeforeStart <- actionEditor.syncRuntimeSectionAndJump(0, 0.25);
clearSectionSyncBeforeStart <- actionEditor.clearRuntimeSectionSync(0);
testAnimation <- eve.Animation();
testSkeleton <- testAnimation.newSkeleton();
testBoneMask <- testAnimation.newBoneMask(testSkeleton);
setBoneMaskBeforeStart <- actionEditor.setRuntimeBoneMask(testBoneMask);
clearBoneMaskBeforeStart <- actionEditor.clearRuntimeBoneMask();
configured <- actionEditor.configureWorkspace(workspace);
workspacePanelCount <- workspace.getPanelCount();
viewport <- actionEditor.setViewport(800.0, 36.0, 120.0);
@@ -210,6 +215,8 @@ TEST_CASE("editor.actionTimeline.scriptUsesCanonicalTransactionsAndWorkspace") {
CHECK(!vm.find("sectionProgressBeforeStart").toTable().get<bool>("ok"));
CHECK(!vm.find("sectionSyncJumpBeforeStart").toTable().get<bool>("ok"));
CHECK(!vm.find("clearSectionSyncBeforeStart").toTable().get<bool>("ok"));
CHECK(!vm.find("setBoneMaskBeforeStart").toTable().get<bool>("ok"));
CHECK(!vm.find("clearBoneMaskBeforeStart").toTable().get<bool>("ok"));
CHECK(vm.find("configured").toTable().get<bool>("ok"));
CHECK_EQ(vm.find("workspacePanelCount").toInt(), 4);
CHECK(vm.find("viewport").toTable().get<bool>("ok"));