diff --git a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts
index f3f0c31..0394474 100644
--- a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts
+++ b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts
@@ -279,9 +279,12 @@ export class Live2dDevTools extends UnoLitElement {
this._controller?.getFilterPipeline().setIntensity(id, v);
}
private _setParamValue(p: string, v: number): void {
- this._controller
- ?.getSemanticLayer()
- .setSemantic(p, v, "override", "manual", 1);
+ this._controller?.getSemanticLayer().holdSemantic(p, v, "manual", 1);
+ this.requestUpdate();
+ }
+ private _resetParamValue(p: string): void {
+ this._controller?.getSemanticLayer().releaseSemantic(p, "manual");
+ this.requestUpdate();
}
private _sectionHeader(
@@ -766,9 +769,13 @@ export class Live2dDevTools extends UnoLitElement {
${p.name}
this._setParamValue(p.name, Number((e.target as HTMLInputElement).value))}/>
${(p.value ?? 0).toFixed(2)}
+ ${this._controller?.getSemanticLayer().hasHeldSemantic(p.name, "manual")
+ ? html``
+ : html``}
`,
)}
diff --git a/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts b/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts
index a1da763..9f1a1f8 100644
--- a/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts
+++ b/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { Live2dDevTools } from "../Live2dDevTools";
import { Live2dRuntimeController } from "@/live2d/runtime/controller";
+import { render, type TemplateResult } from "lit";
interface DevToolsPrivate {
_transitionFSM(state: string): void;
@@ -8,9 +9,12 @@ interface DevToolsPrivate {
_applyFilter(preset: string): void;
_clearFilters(): void;
_setParamValue(name: string, value: number): void;
+ _resetParamValue(name: string): void;
+ _renderParamSection(): TemplateResult;
_setFilterIntensity(id: string, value: number): void;
- _toggleVisible(): void;
+ _handleKeyDown(event: KeyboardEvent): void;
_visible: boolean;
+ _sections: Array<{ id: string; expanded: boolean }>;
_controller: Live2dRuntimeController | null;
}
@@ -82,11 +86,34 @@ describe("Live2dDevTools", () => {
expect(clearSpy).toHaveBeenCalled();
});
- it("slider changes parameter value with manual priority", () => {
+ it("slider holds the parameter with manual priority", () => {
const semanticLayer = controller.getSemanticLayer();
- const setSemanticSpy = vi.spyOn(semanticLayer, "setSemantic");
+ const holdSpy = vi.spyOn(semanticLayer, "holdSemantic");
asPrivate(devtools)._setParamValue("mouthOpen", 5.5);
- expect(setSemanticSpy).toHaveBeenCalledWith("mouthOpen", 5.5, "override", "manual", 1);
+ expect(holdSpy).toHaveBeenCalledWith("mouthOpen", 5.5, "manual", 1);
+ });
+
+ it("reset releases only the manual parameter hold", () => {
+ const semanticLayer = controller.getSemanticLayer();
+ const releaseSpy = vi.spyOn(semanticLayer, "releaseSemantic");
+ asPrivate(devtools)._resetParamValue("mouthOpen");
+ expect(releaseSpy).toHaveBeenCalledWith("mouthOpen", "manual");
+ });
+
+ it("shows a restore control for a manually held parameter", () => {
+ vi.spyOn(controller, "getSemanticParameters").mockReturnValue([{ name: "angleX", value: 17 }]);
+ vi.spyOn(controller.getSemanticLayer(), "hasHeldSemantic").mockReturnValue(true);
+ vi.spyOn(controller.getSemanticLayer(), "getHeldSemantic").mockReturnValue(12);
+ const paramsSection = asPrivate(devtools)._sections.find((section) => section.id === "params");
+ expect(paramsSection).toBeDefined();
+ if (paramsSection) paramsSection.expanded = true;
+ const container = document.createElement("div");
+
+ render(asPrivate(devtools)._renderParamSection(), container);
+
+ expect(container.querySelector('button[aria-label="恢复 angleX 的自动控制"]')).not.toBeNull();
+ expect((container.querySelector('input[type="range"]') as HTMLInputElement).value).toBe("12");
+ expect(container.textContent).toContain("17.00");
});
it("filter intensity slider adjusts effect intensity", () => {
@@ -100,7 +127,7 @@ describe("Live2dDevTools", () => {
describe("visibility toggle", () => {
it("toggle switches visibility state", () => {
const initialVisible = asPrivate(devtools)._visible;
- asPrivate(devtools)._toggleVisible();
+ asPrivate(devtools)._handleKeyDown(new KeyboardEvent("keydown", { key: "D", ctrlKey: true, shiftKey: true }));
const afterToggle = asPrivate(devtools)._visible;
expect(afterToggle).toBe(!initialVisible);
});
diff --git a/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts b/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts
index 0212e04..ec7bf4e 100644
--- a/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts
+++ b/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { BehaviorFSM } from "../fsm";
import { mergeProfiles, buildProfile } from "../profile";
import type { BehaviorState, BehaviorProfile, BehaviorContext } from "../types";
+import { SemanticParameterLayer } from "../../semantic";
+import { ParameterCoordinator } from "../../controller/coordinator";
describe("BehaviorFSM", () => {
function createMockContext(): BehaviorContext {
@@ -20,6 +22,8 @@ describe("BehaviorFSM", () => {
} as unknown as NonNullable,
semanticLayer: {
setSemantic: vi.fn(),
+ holdSemantic: vi.fn(),
+ releaseSemantic: vi.fn(),
getSemantic: vi.fn(() => 0),
hasSemantic: vi.fn(() => true),
getCapabilityProfile: vi.fn(() => ({
@@ -162,6 +166,36 @@ describe("BehaviorFSM", () => {
});
describe("entry profile application", () => {
+ it("keeps an additive state value stable across frames and removes it on exit", () => {
+ const values = new Float32Array(1);
+ const layer = new SemanticParameterLayer();
+ (layer as unknown as { resolved: Map }).resolved =
+ new Map([["angleX", { id: "PARAM_ANGLE_X", index: 0 }]]);
+ (layer as unknown as { accessor: object }).accessor = {
+ getValue: (index: number) => values[index],
+ setValue: (index: number, value: number) => { values[index] = value; },
+ getMin: () => -30,
+ getMax: () => 30,
+ };
+ const coordinator = new ParameterCoordinator(layer);
+ layer.setCoordinator(coordinator);
+ const fsm = new BehaviorFSM({ semanticLayer: layer }, { defaultDebounceMs: 0 });
+ fsm.registerState({ name: "happy", entryProfile: {
+ semanticParameters: { angleX: { value: 5, blendMode: "add" } },
+ } });
+ fsm.registerState({ name: "idle" });
+
+ fsm.transitionTo("happy");
+ for (let frame = 0; frame < 3; frame++) {
+ coordinator.flush();
+ expect(values[0]).toBe(5);
+ values[0] = 0; // engine loadParameters()
+ }
+
+ fsm.transitionTo("idle");
+ coordinator.flush();
+ expect(values[0]).toBe(0);
+ });
it("applies motion layer effects on state entry", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
@@ -215,13 +249,37 @@ describe("BehaviorFSM", () => {
fsm.transitionTo("happy");
- expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith(
+ expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith(
"mouthSmile",
0.6,
+ "fsm",
+ 2,
"override",
+ );
+ });
+
+ it("holds add-mode semantic parameters for the state's lifetime", () => {
+ const ctx = createMockContext();
+ const fsm = new BehaviorFSM(ctx);
+ fsm.registerState({
+ name: "happy",
+ entryProfile: {
+ semanticParameters: {
+ mouthSmile: { value: 0.2, blendMode: "add" },
+ },
+ },
+ });
+
+ fsm.transitionTo("happy");
+
+ expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith(
+ "mouthSmile",
+ 0.2,
"fsm",
2,
+ "add",
);
+ expect(ctx.semanticLayer!.setSemantic).not.toHaveBeenCalled();
});
it("applies procedural overrides on state entry", () => {
@@ -280,7 +338,7 @@ describe("BehaviorFSM", () => {
expect(ctx.filterPipeline!.remove).toHaveBeenCalledWith("handle-happy-glow");
});
- it("resets semantic parameters on state exit", () => {
+ it("releases held semantic parameters on state exit", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
fsm.registerState({
@@ -296,15 +354,46 @@ describe("BehaviorFSM", () => {
fsm.transitionTo("happy");
fsm.transitionTo("idle");
- expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith(
+ expect(ctx.semanticLayer!.releaseSemantic).toHaveBeenCalledWith(
"mouthSmile",
- 0,
- "override",
"fsm",
- 2,
);
});
+ it("releases the entry parameter even when exitProfile names other parameters", () => {
+ const ctx = createMockContext();
+ const fsm = new BehaviorFSM(ctx);
+ fsm.registerState({
+ name: "happy",
+ entryProfile: { semanticParameters: { mouthSmile: { value: 0.2, blendMode: "add" } } },
+ exitProfile: { semanticParameters: { angleX: { value: 0 } } },
+ });
+ fsm.registerState({ name: "idle" });
+
+ fsm.transitionTo("happy");
+ fsm.transitionTo("idle");
+
+ expect(ctx.semanticLayer!.releaseSemantic).toHaveBeenCalledWith("mouthSmile", "fsm");
+ expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith("angleX", 0, "fsm", 2);
+ });
+
+ it("keeps an explicit exit reset until a later state transition", () => {
+ const ctx = createMockContext();
+ const fsm = new BehaviorFSM(ctx, { defaultDebounceMs: 0 });
+ fsm.registerState({
+ name: "happy",
+ exitProfile: { semanticParameters: { browLY: { value: 0.8 } } },
+ });
+ fsm.registerState({ name: "idle" });
+ fsm.registerState({ name: "talking" });
+
+ fsm.transitionTo("happy");
+ fsm.transitionTo("idle");
+ expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith("browLY", 0, "fsm", 2);
+ fsm.transitionTo("talking");
+ expect(ctx.semanticLayer!.releaseSemantic).toHaveBeenCalledWith("browLY", "fsm");
+ });
+
it("reverts procedural overrides on state exit", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
diff --git a/packages/live2d/src/runtime/behavior/fsm.ts b/packages/live2d/src/runtime/behavior/fsm.ts
index 60c13e6..f25129a 100644
--- a/packages/live2d/src/runtime/behavior/fsm.ts
+++ b/packages/live2d/src/runtime/behavior/fsm.ts
@@ -18,6 +18,7 @@ export class BehaviorFSM {
// Track applied effects so they can be reversed on exit
private activeFilterHandles = new Map();
private activeMotionLayers = new Set();
+ private activeSemanticParameters = new Set();
private proceduralModuleStates = new Map();
constructor(context: BehaviorContext, config: BehaviorFSMConfig = {}) {
@@ -116,9 +117,12 @@ export class BehaviorFSM {
fromState.onExit(this.context);
}
- // 2. Revert effects from current state
+ // 2. Revert effects from current state. Release entry holds first so an
+ // explicit exit reset can survive in the following state.
+ this.releaseActiveSemanticParameters();
if (fromState?.exitProfile) {
this.revertProfile(fromState.exitProfile);
+ this.applyExitSemanticReset(fromState.exitProfile);
} else if (fromState?.entryProfile) {
this.revertProfile(fromState.entryProfile);
}
@@ -209,15 +213,15 @@ export class BehaviorFSM {
if (profile.semanticParameters && semanticLayer) {
for (const [name, config] of Object.entries(profile.semanticParameters)) {
- if (semanticLayer.hasSemantic(name)) {
- semanticLayer.setSemantic(
- name,
- config.value,
- config.blendMode ?? "override",
- "fsm",
- 2,
- );
- }
+ if (!semanticLayer.hasSemantic(name)) continue;
+ semanticLayer.holdSemantic(
+ name,
+ config.value,
+ "fsm",
+ 2,
+ config.blendMode ?? "override",
+ );
+ this.activeSemanticParameters.add(name);
}
}
@@ -248,7 +252,6 @@ export class BehaviorFSM {
const {
motionLayerSystem,
filterPipeline,
- semanticLayer,
proceduralSystem,
} = this.context;
@@ -269,12 +272,6 @@ export class BehaviorFSM {
}
}
- if (profile.semanticParameters && semanticLayer) {
- for (const name of Object.keys(profile.semanticParameters)) {
- semanticLayer.setSemantic(name, 0, "override", "fsm", 2);
- }
- }
-
if (profile.proceduralOverrides && proceduralSystem) {
for (const moduleName of Object.keys(profile.proceduralOverrides)) {
const previousState = this.proceduralModuleStates.get(moduleName);
@@ -290,4 +287,22 @@ export class BehaviorFSM {
}
}
}
+
+ private releaseActiveSemanticParameters(): void {
+ for (const name of this.activeSemanticParameters) {
+ this.context.semanticLayer?.releaseSemantic(name, "fsm");
+ }
+ this.activeSemanticParameters.clear();
+ }
+
+ private applyExitSemanticReset(profile: BehaviorProfile): void {
+ if (!profile.semanticParameters || !this.context.semanticLayer) return;
+ for (const name of Object.keys(profile.semanticParameters)) {
+ if (!this.context.semanticLayer.hasSemantic(name)) continue;
+ // Exit profiles historically reset named parameters to zero, regardless
+ // of their configured value. Keep that reset across engine frames.
+ this.context.semanticLayer.holdSemantic(name, 0, "fsm", 2);
+ this.activeSemanticParameters.add(name);
+ }
+ }
}
diff --git a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts
index 6cd2522..bfaaeb1 100644
--- a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts
+++ b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts
@@ -1,6 +1,9 @@
+import type { Ticker } from "pixi.js";
+import type { Live2DModel } from "untitled-pixi-live2d-engine";
import { describe, expect, it, vi } from "vitest";
-import { Live2dRuntimeController } from "../controller";
import type { BehaviorFSM } from "../../behavior";
+import { Live2dRuntimeController } from "../controller";
+import type { ParameterCoordinator } from "../coordinator";
describe("Live2dRuntimeController", () => {
it("creates with default config", () => {
@@ -139,6 +142,99 @@ describe("Live2dRuntimeController", () => {
expect(history.length).toBe(10);
});
+ it("getSemanticParameters reports the last rendered value after engine restore", () => {
+ const controller = new Live2dRuntimeController();
+ const values = new Float32Array(1);
+ const coreModel = {
+ _model: {
+ parameters: {
+ ids: ["PARAM_ANGLE_X"],
+ values,
+ minimumValues: new Float32Array([-30]),
+ maximumValues: new Float32Array([30]),
+ defaultValues: new Float32Array(1),
+ },
+ },
+ getParameterValueByIndex: (index: number) => values[index] ?? 0,
+ setParameterValueByIndex: (index: number, value: number) => {
+ values[index] = value;
+ },
+ };
+
+ const layer = controller.getSemanticLayer();
+ layer.detectFromModel({ internalModel: { coreModel } });
+ layer.holdSemantic("angleX", 12, "manual", 1);
+ layer.setSemantic("angleX", 5, "add", "procedural", 4);
+ const coordinator = (controller as unknown as { coordinator: ParameterCoordinator }).coordinator;
+ coordinator.flush();
+ layer.captureRenderedValues();
+ values[0] = 0; // The engine restores its saved baseline after rendering.
+
+ // The hold is applied per frame and the engine restores its own value
+ // afterwards, so the raw parameter still reads 0.
+ expect(layer.getSemantic("angleX")).toBe(0);
+ expect(controller.getSemanticParameters()).toEqual([
+ { name: "angleX", value: 17 },
+ ]);
+ });
+
+ it("flushes and captures through the model event, then unsubscribes on destroy", () => {
+ const values = new Float32Array(1);
+ const listeners = new Set<() => void>();
+ const on = vi.fn((_event: string, listener: () => void) => listeners.add(listener));
+ const off = vi.fn((_event: string, listener: () => void) => listeners.delete(listener));
+ const model = {
+ internalModel: {
+ coreModel: {
+ _model: {
+ parameters: {
+ ids: ["PARAM_ANGLE_X"],
+ values,
+ minimumValues: new Float32Array([-30]),
+ maximumValues: new Float32Array([30]),
+ defaultValues: new Float32Array(1),
+ },
+ },
+ getParameterValueByIndex: (index: number) => values[index] ?? 0,
+ setParameterValueByIndex: (index: number, value: number) => {
+ values[index] = value;
+ },
+ },
+ on,
+ off,
+ },
+ filters: null,
+ } as unknown as Live2DModel;
+ const ticker = { add: vi.fn(), remove: vi.fn() } as unknown as Ticker;
+ const controller = new Live2dRuntimeController({
+ motionLayers: { enabled: false },
+ behaviorFSM: { enabled: false },
+ emotionTimeline: { enabled: false },
+ proceduralAnimation: { enabled: false },
+ });
+
+ controller.initialize(model, ticker);
+ expect(on).toHaveBeenCalledWith("beforeModelUpdate", expect.any(Function));
+ const layer = controller.getSemanticLayer();
+ layer.setSemantic("angleX", 5, "add", "test");
+ for (const listener of listeners) listener();
+ expect(values[0]).toBe(5);
+ expect(controller.getSemanticParameters()).toContainEqual({ name: "angleX", value: 5 });
+
+ values[0] = 0; // The engine restores its saved baseline after rendering.
+ layer.setSemantic("angleX", 5, "add", "test");
+ for (const listener of listeners) listener();
+ expect(values[0]).toBe(5);
+
+ controller.destroy(ticker);
+ expect(off).toHaveBeenCalledWith("beforeModelUpdate", expect.any(Function));
+ expect(listeners.size).toBe(0);
+ values[0] = 0;
+ layer.setSemantic("angleX", 5, "add", "test");
+ for (const listener of listeners) listener();
+ expect(values[0]).toBe(0);
+ });
+
it("getSemanticParameters returns empty before detection", () => {
const controller = new Live2dRuntimeController();
expect(controller.getSemanticParameters()).toEqual([]);
diff --git a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts
index e75cba4..6812b89 100644
--- a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts
+++ b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { ParameterCoordinator } from "../coordinator";
import { SemanticParameterLayer } from "../../semantic";
+import { EmotionTimeline } from "../../emotion/timeline";
+import { ProceduralAnimator } from "../../procedural/animator";
+import { MutableParameterSet } from "../../procedural/parameter-set";
import { SystemPriority } from "../types";
function createMockSemanticLayer(): SemanticParameterLayer {
@@ -10,9 +13,13 @@ function createMockSemanticLayer(): SemanticParameterLayer {
["angleX", { id: "PARAM_ANGLE_X", index: 1 }],
["eyeLOpen", { id: "PARAM_EYE_L_OPEN", index: 2 }],
]);
- const setValueMock = vi.fn();
+ // Stateful, like the real accessor: `add` writes read the value back.
+ const values = new Float32Array(3);
+ const setValueMock = vi.fn((index: number, value: number) => {
+ values[index] = value;
+ });
(layer as unknown as Record).accessor = {
- getValue: () => 0,
+ getValue: (index: number) => values[index],
setValue: setValueMock,
getMin: () => -30,
getMax: () => 30,
@@ -92,6 +99,17 @@ describe("ParameterCoordinator", () => {
expect(coordinator.getConflictLog()).toEqual([]);
});
+ it("keeps the first override when priorities are equal", () => {
+ coordinator.queueWrite("mouthOpen", 0.5, "override", "first", SystemPriority.FSM);
+ coordinator.queueWrite("mouthOpen", 0.8, "override", "second", SystemPriority.FSM);
+ coordinator.flush();
+
+ const accessor = getAccessor(semanticLayer);
+ expect(accessor.setValue).toHaveBeenCalledWith(0, 0.5);
+ expect(coordinator.getConflictLog()[0].winningSystem).toBe("first");
+ expect(coordinator.getConflictLog()[0].losingSystem).toBe("second");
+ });
+
it("does not log conflict for add blend mode", () => {
coordinator.queueWrite("mouthOpen", 0.5, "add", "fsm", SystemPriority.FSM);
coordinator.queueWrite("mouthOpen", 0.3, "add", "emotion", SystemPriority.EMOTION);
@@ -162,7 +180,7 @@ describe("ParameterCoordinator", () => {
});
describe("per-frame isolation", () => {
- it("clears queue after flush", () => {
+ it("clears the queue after flush", () => {
coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM);
coordinator.flush();
@@ -184,4 +202,473 @@ describe("ParameterCoordinator", () => {
expect(accessor.setValue).toHaveBeenCalledWith(1, 10);
});
});
+
+ describe("engine parameter lifecycle", () => {
+ /**
+ * Stateful mock reproducing the engine's parameter lifecycle, in the order
+ * `CubismInternalModel.update` / `CubismLegacyInternalModel.update` run it:
+ *
+ * motions → saveParameters → engine auto-updates → beforeModelUpdate
+ * → model.update() → loadParameters
+ *
+ * `rendered` is the value the model is drawn with, `baseline` the value the
+ * engine keeps for the next frame.
+ */
+ function createEngineRig(
+ options: {
+ /** Parameters the engine itself wrote this frame, before its baseline. */
+ engineWrite?: Record;
+ /** Parameters the engine adds on top after its baseline (breath, focus). */
+ engineAdds?: Record;
+ } = {},
+ ) {
+ const ids = ["PARAM_ANGLE_X", "PARAM_BREATH"];
+ const values = new Float32Array(ids.length);
+ const baseline = new Float32Array(ids.length);
+ const rendered = new Float32Array(ids.length);
+ const minimums = new Float32Array([-30, 0]);
+ const maximums = new Float32Array([30, 1]);
+
+ const layer = new SemanticParameterLayer();
+ (layer as unknown as Record).resolved = new Map([
+ ["angleX", { id: ids[0], index: 0 }],
+ ["breath", { id: ids[1], index: 1 }],
+ ]);
+ const setValueCalls: Array<[number, number]> = [];
+ (layer as unknown as Record).accessor = {
+ getValue: (index: number) => values[index],
+ setValue: (index: number, value: number) => {
+ setValueCalls.push([index, value]);
+ // Same as setParameterValueByIndex(index, value) with weight 1.
+ values[index] = value;
+ },
+ getMin: (index: number) => minimums[index],
+ getMax: (index: number) => maximums[index],
+ };
+
+ const coordinator = new ParameterCoordinator(layer);
+ layer.setCoordinator(coordinator);
+
+ /** One engine frame: motions → save → auto-updates → flush → render → restore. */
+ const engineFrame = () => {
+ for (const [id, value] of Object.entries(options.engineWrite ?? {})) {
+ values[ids.indexOf(id)] = value;
+ }
+
+ // saveParameters()
+ baseline.set(values);
+
+ // The engine's own per-frame writes (breath, focus, physics, pose).
+ for (const [id, value] of Object.entries(options.engineAdds ?? {})) {
+ const index = ids.indexOf(id);
+ values[index] = Math.max(
+ minimums[index],
+ Math.min(maximums[index], values[index] + value),
+ );
+ }
+
+ // beforeModelUpdate: our writes are visible in this frame only.
+ coordinator.flush();
+ layer.captureRenderedValues();
+ rendered.set(values);
+
+ // loadParameters()
+ values.set(baseline);
+ };
+
+ /** One plugin frame: run the engine, then queue the next write. */
+ const frame = (addValue: number | null) => {
+ engineFrame();
+ if (addValue !== null) {
+ coordinator.queueWrite(
+ "angleX",
+ addValue,
+ "add",
+ "procedural",
+ SystemPriority.PROCEDURAL,
+ );
+ }
+ };
+
+ return {
+ coordinator,
+ layer,
+ frame,
+ engineFrame,
+ /** Value the model was last rendered with. */
+ angleX: () => rendered[0],
+ /** Value the engine keeps for the next frame. */
+ baselineAngleX: () => baseline[0],
+ setValueCalls,
+ };
+ }
+
+ it("does not accumulate add writes across frames", () => {
+ const rig = createEngineRig();
+ for (let i = 0; i < 20; i++) rig.frame(15);
+
+ // 15 per frame on top of the engine's value, not 15 * 20 clamped to 30.
+ expect(rig.angleX()).toBe(15);
+ // The engine drops the contribution when it restores its baseline.
+ expect(rig.baselineAngleX()).toBe(0);
+ });
+
+ it("adds on top of a parameter the engine writes itself", () => {
+ const rig = createEngineRig({ engineWrite: { PARAM_ANGLE_X: 5 } });
+ for (let i = 0; i < 20; i++) rig.frame(15);
+
+ // engine 5 + our 15, stable instead of drifting upwards.
+ expect(rig.angleX()).toBe(20);
+ expect(rig.baselineAngleX()).toBe(5);
+ });
+
+ it("adds on top of the engine's own per-frame writes", () => {
+ const rig = createEngineRig({ engineAdds: { PARAM_ANGLE_X: 3 } });
+ for (let i = 0; i < 20; i++) rig.frame(15);
+
+ // The engine's own contribution runs before `beforeModelUpdate`, so the
+ // add lands on top of it; the baseline still only holds the engine value.
+ expect(rig.angleX()).toBe(18);
+ expect(rig.baselineAngleX()).toBe(0);
+ });
+
+ it("releases the contribution once the writer stops", () => {
+ const rig = createEngineRig();
+ for (let i = 0; i < 10; i++) rig.frame(15);
+ expect(rig.angleX()).toBe(15);
+
+ rig.frame(null);
+ // The add queued by the previous frame is applied once more...
+ expect(rig.angleX()).toBe(15);
+ // ...and gone on the frame after, since the engine restored its baseline.
+ rig.frame(null);
+ expect(rig.angleX()).toBe(0);
+ });
+
+ it("keeps float32 values stable instead of drifting", () => {
+ const rig = createEngineRig();
+ for (let i = 0; i < 30; i++) rig.frame(0.1);
+
+ expect(rig.angleX()).toBeCloseTo(0.1, 6);
+ });
+
+ it("stacks add contributions on top of an override", () => {
+ const rig = createEngineRig();
+ rig.coordinator.queueWrite("angleX", 5, "override", "emotion", SystemPriority.EMOTION);
+ rig.coordinator.queueWrite("angleX", 2, "add", "procedural", SystemPriority.PROCEDURAL);
+
+ rig.engineFrame();
+
+ expect(rig.angleX()).toBe(7);
+ });
+
+ it("lets an override take the parameter over without leaving add residue", () => {
+ const rig = createEngineRig();
+ for (let i = 0; i < 5; i++) rig.frame(15);
+ rig.frame(null); // apply the add queued by the previous frame
+ expect(rig.angleX()).toBe(15);
+
+ rig.coordinator.queueWrite("angleX", 5, "override", "emotion", SystemPriority.EMOTION);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+
+ // Next frame the engine's own value is back: the add contribution was not
+ // left behind anywhere.
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(0);
+ });
+
+ it("keeps an override for one frame only unless it is written again", () => {
+ // A queued override is applied to the frame it was queued in. It does not
+ // reach the engine's baseline, so a caller that wants it to hold has to
+ // queue it every frame - or use holdOverride(), which does that for it.
+ const rig = createEngineRig();
+ rig.coordinator.queueWrite("angleX", 5, "override", "manual", SystemPriority.MANUAL);
+
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ expect(rig.baselineAngleX()).toBe(0);
+
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(0);
+ });
+
+ it("keeps a completed direct emotion through later engine frames", () => {
+ const rig = createEngineRig();
+ const now = vi.spyOn(performance, "now").mockReturnValue(0);
+ try {
+ const timeline = new EmotionTimeline(
+ { semanticLayer: rig.layer },
+ { defaultDuration: 100, minDuration: 0, defaultEasing: "linear" },
+ );
+ timeline.registerEmotion("pose", { parameters: { angleX: 5 } });
+ timeline.transitionTo("pose");
+ now.mockReturnValue(100);
+ timeline.update();
+
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ expect(rig.baselineAngleX()).toBe(0);
+
+ timeline.destroy();
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(0);
+ } finally {
+ now.mockRestore();
+ }
+ });
+
+ it("keeps a completed animation and lets a new animation take over", () => {
+ const rig = createEngineRig();
+ const animator = new ProceduralAnimator(rig.layer);
+ const params = new MutableParameterSet();
+ const queueOutputs = () => {
+ params.forEach((name, value, blendMode) => {
+ rig.layer.setSemantic(name, value, blendMode, "procedural", SystemPriority.PROCEDURAL);
+ });
+ };
+
+ void animator.animate({ target: "angleX", to: 5, duration: 100, easing: (t) => t });
+ animator.update(100, params);
+ queueOutputs();
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+
+ void animator.animate({ target: "angleX", to: 10, duration: 100, easing: (t) => t });
+ params.clear();
+ animator.update(50, params);
+ queueOutputs();
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(7.5);
+ animator.releaseHeldTargets();
+ });
+
+ it("reset() drops pending writes", () => {
+ const rig = createEngineRig();
+ for (let i = 0; i < 5; i++) rig.frame(15);
+ rig.coordinator.queueWrite("angleX", 25, "add", "procedural", SystemPriority.PROCEDURAL);
+ rig.coordinator.queueWrite("angleX", 25, "override", "manual", SystemPriority.MANUAL);
+
+ rig.coordinator.reset();
+ const callsBefore = rig.setValueCalls.length;
+ rig.engineFrame();
+
+ expect(rig.setValueCalls.length).toBe(callsBefore);
+ });
+
+ it("re-applies a held override on every frame", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+
+ for (let i = 0; i < 3; i++) {
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ }
+
+ // The hold is re-applied per frame, it never reaches the engine's
+ // baseline.
+ expect(rig.baselineAngleX()).toBe(0);
+ });
+
+ it("keeps a held override against a lower-priority per-frame override", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+
+ for (let i = 0; i < 3; i++) {
+ rig.coordinator.queueWrite(
+ "angleX",
+ 1,
+ "override",
+ "procedural",
+ SystemPriority.PROCEDURAL,
+ );
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ }
+
+ // A continuing held-vs-queued conflict is reported once, not per frame.
+ expect(rig.coordinator.getConflictLog()).toEqual([
+ expect.objectContaining({ winningSystem: "manual", losingSystem: "procedural" }),
+ ]);
+ });
+
+ it("preserves queued conflict diagnostics when a hold wins", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+ rig.coordinator.queueWrite("angleX", 3, "override", "emotion", SystemPriority.EMOTION);
+ rig.coordinator.queueWrite("angleX", 2, "override", "motion", SystemPriority.MOTION);
+ rig.engineFrame();
+
+ expect(rig.angleX()).toBe(5);
+ const log = rig.coordinator.getConflictLog();
+ expect(log).toEqual([
+ expect.objectContaining({ winningSystem: "emotion", losingSystem: "motion" }),
+ expect.objectContaining({ winningSystem: "manual", losingSystem: "emotion" }),
+ expect.objectContaining({ winningSystem: "manual", losingSystem: "motion" }),
+ ]);
+
+ rig.coordinator.queueWrite("angleX", 3, "override", "emotion", SystemPriority.EMOTION);
+ rig.coordinator.queueWrite("angleX", 2, "override", "motion", SystemPriority.MOTION);
+ rig.engineFrame();
+ expect(rig.coordinator.getConflictLog()).toHaveLength(4);
+ });
+
+ it("reports a held loser once when a higher-priority queue wins", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "fsm", SystemPriority.FSM);
+ for (let frame = 0; frame < 3; frame++) {
+ rig.coordinator.queueWrite("angleX", 9, "override", "manual", SystemPriority.MANUAL);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(9);
+ }
+
+ expect(rig.coordinator.getConflictLog()).toEqual([
+ expect.objectContaining({ winningSystem: "manual", losingSystem: "fsm" }),
+ ]);
+ });
+
+ it("keeps an established hold over a same-source queued override on a priority tie", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+ rig.coordinator.queueWrite("angleX", 9, "override", "manual", SystemPriority.MANUAL);
+
+ rig.engineFrame();
+
+ expect(rig.angleX()).toBe(5);
+ });
+
+ it("stacks add contributions on top of a held override", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+
+ for (let i = 0; i < 3; i++) {
+ rig.coordinator.queueWrite(
+ "angleX",
+ 2,
+ "add",
+ "procedural",
+ SystemPriority.PROCEDURAL,
+ );
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(7);
+ }
+ });
+
+ it("keeps an FSM additive contribution for every frame and releases it", () => {
+ const rig = createEngineRig({ engineWrite: { PARAM_ANGLE_X: 3 } });
+ rig.coordinator.holdWrite("angleX", 5, "add", "fsm", SystemPriority.FSM);
+
+ for (let i = 0; i < 3; i++) {
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(8);
+ expect(rig.layer.getRenderedSemantic("angleX")).toBe(8);
+ expect(rig.layer.getSemantic("angleX")).toBe(3);
+ }
+
+ rig.coordinator.releaseOverride("angleX", "fsm");
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(3);
+ });
+
+ it("restores the FSM hold after a manual hold is released", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "fsm", SystemPriority.FSM);
+ rig.coordinator.holdOverride("angleX", 10, "manual", SystemPriority.MANUAL);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(10);
+ expect(rig.coordinator.getHeldValue("angleX", "manual")).toBe(10);
+ expect(rig.coordinator.getHeldValue("angleX", "fsm")).toBe(5);
+ expect(rig.coordinator.getConflictLog()[0].winningSystem).toBe("manual");
+ expect(rig.coordinator.getConflictLog()[0].losingSystem).toBe("fsm");
+
+ rig.coordinator.releaseOverride("angleX", "manual");
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ expect(rig.coordinator.getHeldValue("angleX", "manual")).toBeUndefined();
+ });
+
+ it("keeps a lower-priority hold registered while manual control is active", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 10, "manual", SystemPriority.MANUAL);
+ rig.coordinator.holdOverride("angleX", 5, "fsm", SystemPriority.FSM);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(10);
+
+ rig.coordinator.releaseOverride("angleX", "manual");
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+ });
+
+ it("reports the clamped rendered value instead of the hold target", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 25, "manual", SystemPriority.MANUAL);
+ rig.coordinator.queueWrite("angleX", 10, "add", "procedural", SystemPriority.PROCEDURAL);
+ rig.engineFrame();
+
+ expect(rig.angleX()).toBe(30);
+ expect(rig.layer.getRenderedSemantic("angleX")).toBe(30);
+ expect(rig.layer.getSemantic("angleX")).toBe(0);
+ });
+
+ it("snapshots engine-only parameter changes even when the plugin has no writes", () => {
+ const rig = createEngineRig({ engineAdds: { PARAM_ANGLE_X: 3 } });
+ rig.engineFrame();
+
+ expect(rig.layer.getRenderedSemantic("angleX")).toBe(3);
+ expect(rig.layer.getSemantic("angleX")).toBe(0);
+ });
+
+ it("releases a held override back to the engine", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+
+ rig.coordinator.releaseOverride("angleX", "manual");
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(0);
+ });
+
+ it("does not let a lower-priority hold replace a higher-priority one", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+ rig.coordinator.holdOverride("angleX", 9, "fsm", SystemPriority.FSM);
+
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+
+ const log = rig.coordinator.getConflictLog();
+ expect(log).toHaveLength(1);
+ expect(log[0].winningSystem).toBe("manual");
+ expect(log[0].losingSystem).toBe("fsm");
+ });
+
+ it("releaseOverride ignores a different source", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+
+ rig.coordinator.releaseOverride("angleX", "fsm");
+ rig.engineFrame();
+
+ expect(rig.angleX()).toBe(5);
+ });
+
+ it("reset() drops held overrides", () => {
+ const rig = createEngineRig();
+ rig.coordinator.holdOverride("angleX", 5, "manual", SystemPriority.MANUAL);
+ rig.engineFrame();
+ expect(rig.angleX()).toBe(5);
+
+ rig.coordinator.reset();
+ const callsBefore = rig.setValueCalls.length;
+ rig.engineFrame();
+
+ expect(rig.setValueCalls.length).toBe(callsBefore);
+ expect(rig.angleX()).toBe(0);
+ });
+ });
});
diff --git a/packages/live2d/src/runtime/controller/controller.ts b/packages/live2d/src/runtime/controller/controller.ts
index 0b9b698..48cc622 100644
--- a/packages/live2d/src/runtime/controller/controller.ts
+++ b/packages/live2d/src/runtime/controller/controller.ts
@@ -58,6 +58,8 @@ export class Live2dRuntimeController {
// 1. Semantic parameter detection
this.semanticLayer.detectFromModel(model);
+ // Drop writes queued against the previous model.
+ this.coordinator.reset();
// 2. Motion layer system
if (this.config.motionLayers?.enabled !== false) {
@@ -134,20 +136,27 @@ export class Live2dRuntimeController {
this._tickerCallbacks.push(() => ticker.remove(emotionTicker));
}
- // 7. Hook engine's internalModel.update so our parameter flush runs AFTER
- // engine auto-updates (physics, blink, expression, idle motion).
- // This ensures manual effects override engine values instead of being overwritten.
- const internalModel = this.extractInternalModel(model);
- if (internalModel) {
- const originalUpdate = internalModel.update.bind(internalModel);
- internalModel.update = (dt: number, now?: number) => {
- originalUpdate(dt, now);
- this.coordinator.flush();
- };
- this._tickerCallbacks.push(() => {
- internalModel.update = originalUpdate;
- });
- }
+ // 7. Apply our parameter writes from inside the engine's own update.
+ //
+ // `beforeModelUpdate` runs after the engine saved its parameter baseline and
+ // before the model is rendered with those parameters. The engine restores
+ // that baseline at the end of the same frame, so a write is visible for
+ // exactly one frame: an `add` write is applied on top of the engine's
+ // current value and is dropped afterwards, which is what keeps repeated
+ // `add` writes from accumulating.
+ //
+ // `Live2DModel.internalModel` is a required field and `initialize()` only
+ // runs for a model that finished loading, so no runtime guard is needed; if
+ // the engine ever makes it optional the type checker points at this line.
+ const internalModel = model.internalModel;
+ const onBeforeModelUpdate = () => {
+ this.coordinator.flush();
+ this.semanticLayer.captureRenderedValues();
+ };
+ internalModel.on("beforeModelUpdate", onBeforeModelUpdate);
+ this._tickerCallbacks.push(() => {
+ internalModel.off("beforeModelUpdate", onBeforeModelUpdate);
+ });
// Attach filter pipeline to model
this.filterPipeline.attachTo(model);
@@ -224,12 +233,18 @@ export class Live2dRuntimeController {
/**
* Get current semantic parameter values for DevTools display.
+ *
+ * The engine restores its baseline after rendering, so read the snapshot
+ * captured inside `beforeModelUpdate` instead of the restored raw value.
*/
getSemanticParameters(): Array<{ name: string; value: number | undefined }> {
const profile = this.semanticLayer.getCapabilityProfile();
const result: Array<{ name: string; value: number | undefined }> = [];
for (const name of profile.detected.keys()) {
- result.push({ name, value: this.semanticLayer.getSemantic(name) });
+ result.push({
+ name,
+ value: this.semanticLayer.getRenderedSemantic(name),
+ });
}
return result;
}
@@ -276,16 +291,6 @@ export class Live2dRuntimeController {
// ── Private helpers ─────────────────────────────────────────────
- private extractInternalModel(
- model: Live2DModel,
- ): { update(dt: number, now?: number): void } | undefined {
- const record = model as unknown as Record;
- const internalModel = record.internalModel as
- | { update(dt: number, now?: number): void }
- | undefined;
- return internalModel;
- }
-
private getTransitionProgress(): number {
return this.emotionTimeline?.getTransitionProgress() ?? 0;
}
diff --git a/packages/live2d/src/runtime/controller/coordinator.ts b/packages/live2d/src/runtime/controller/coordinator.ts
index 595dbcf..3b87d5e 100644
--- a/packages/live2d/src/runtime/controller/coordinator.ts
+++ b/packages/live2d/src/runtime/controller/coordinator.ts
@@ -10,9 +10,43 @@ interface QueuedWrite {
priority: SystemPriority;
}
+interface HeldWrite {
+ value: number;
+ blendMode: BlendMode;
+ source: string;
+ priority: SystemPriority;
+}
+
+/**
+ * Applies the queued parameter writes from inside the engine's own update.
+ *
+ * The engine saves the current parameter values as a baseline every frame
+ * (`CubismModel.saveParameters` / `saveParam`) and restores that baseline at the
+ * end of the same frame (`loadParameters` / `loadParam`). A write made after the
+ * restore therefore becomes part of the next frame's baseline, which made an
+ * `add` write - relative to the current value - stack on top of its own previous
+ * result until the parameter reached its limit.
+ *
+ * Writing from the engine's `beforeModelUpdate` event instead - after the
+ * baseline was saved and before the model is rendered with the parameters -
+ * keeps a write visible for that frame only: an `add` is applied on top of the
+ * engine's current value and is dropped when the engine restores its baseline,
+ * so no bookkeeping of previous contributions is needed.
+ *
+ * Writes come in two flavours:
+ *
+ * - `queueWrite()` - a one-frame contribution. Every `add`, and every override
+ * a subsystem re-sends each frame (blink, motion layers), belongs here.
+ * - `holdWrite()` - a contribution that must survive frames the writer does not
+ * know about: the DevTools slider or an FSM state profile sets its value once,
+ * so the coordinator re-applies it on every flush until `releaseOverride()`.
+ */
export class ParameterCoordinator {
private queue = new Map();
+ private held = new Map>();
private conflictLog: ConflictEntry[] = [];
+ private activeHeldQueueConflicts = new Set();
+ private nextHeldQueueConflicts = new Set();
private semanticLayer: SemanticParameterLayer;
private maxLogSize: number;
@@ -24,6 +58,19 @@ export class ParameterCoordinator {
this.maxLogSize = options.maxLogSize ?? 50;
}
+ /**
+ * Drop pending writes and held overrides.
+ *
+ * Must be called when the model changes: pending writes were queued against
+ * the previous model's parameters.
+ */
+ reset(): void {
+ this.queue.clear();
+ this.held.clear();
+ this.activeHeldQueueConflicts.clear();
+ this.nextHeldQueueConflicts.clear();
+ }
+
/**
* Queue a parameter write. Writes are not applied until flush() is called.
*/
@@ -40,14 +87,115 @@ export class ParameterCoordinator {
}
/**
- * Resolve all queued writes, detect conflicts, and apply to semantic layer.
- * Should be called once per frame after all subsystems have queued writes.
+ * Hold a source's contribution until it is replaced or released.
+ *
+ * A held value is re-applied on every flush. That is what keeps a write that
+ * happens only once - the DevTools slider, an FSM state profile - taking
+ * effect: the engine restores its own baseline at the end of every frame, so
+ * a queued write is only visible in the frame it was queued in.
+ *
+ * Keep suppressed sources so they can take over when a higher-priority
+ * source releases its hold. Additive holds contribute on every frame.
+ */
+ holdWrite(
+ parameter: string,
+ value: number,
+ blendMode: BlendMode,
+ source: string,
+ priority: SystemPriority,
+ ): void {
+ const sources = this.held.get(parameter) ?? new Map();
+ if (blendMode === "override" && !sources.has(source)) {
+ let existingWinner: HeldWrite | undefined;
+ for (const held of sources.values()) {
+ if (
+ held.blendMode === "override" &&
+ (!existingWinner || held.priority < existingWinner.priority)
+ ) {
+ existingWinner = held;
+ }
+ }
+ if (existingWinner) {
+ const incoming = { value, source };
+ if (priority < existingWinner.priority) {
+ this.logConflict(parameter, incoming, existingWinner);
+ } else {
+ this.logConflict(parameter, existingWinner, incoming);
+ }
+ }
+ }
+ sources.set(source, { value, blendMode, source, priority });
+ this.held.set(parameter, sources);
+ }
+
+ holdOverride(
+ parameter: string,
+ value: number,
+ source: string,
+ priority: SystemPriority,
+ ): void {
+ this.holdWrite(parameter, value, "override", source, priority);
+ }
+
+ /**
+ * Release a held write. With `source` given only that source's hold is
+ * released, so one subsystem cannot drop another's.
+ */
+ releaseOverride(parameter: string, source?: string): void {
+ if (source === undefined) {
+ this.held.delete(parameter);
+ return;
+ }
+ const sources = this.held.get(parameter);
+ sources?.delete(source);
+ if (sources?.size === 0) this.held.delete(parameter);
+ }
+
+ hasHeldWrite(parameter: string, source: string): boolean {
+ return this.held.get(parameter)?.has(source) ?? false;
+ }
+
+ getHeldValue(parameter: string, source: string): number | undefined {
+ return this.held.get(parameter)?.get(source)?.value;
+ }
+
+ /**
+ * Resolve all queued writes, detect conflicts, and apply to the semantic
+ * layer. Driven by the engine's `beforeModelUpdate` event, so the values are
+ * part of that frame's render and are dropped by the engine afterwards.
*/
flush(): void {
+ const heldQueueConflicts = this.nextHeldQueueConflicts;
+ heldQueueConflicts.clear();
for (const [parameter, writes] of this.queue) {
- this.resolveParameter(parameter, writes);
+ this.resolveParameter(parameter, writes, heldQueueConflicts);
+ }
+ // A held contribution whose parameter nobody wrote this frame still has to be
+ // re-applied: the engine restored its baseline at the end of the last frame.
+ // The queue still holds this frame's parameters, so it doubles as the lookup.
+ if (this.held.size > 0) {
+ for (const parameter of this.held.keys()) {
+ if (!this.queue.has(parameter)) {
+ this.resolveParameter(parameter, [], heldQueueConflicts);
+ }
+ }
}
this.queue.clear();
+ this.nextHeldQueueConflicts = this.activeHeldQueueConflicts;
+ this.activeHeldQueueConflicts = heldQueueConflicts;
+ }
+
+ /**
+ * Write an absolute value, temporarily detaching the coordinator so that the
+ * write is not re-queued.
+ */
+ private applyAbsolute(parameter: string, value: number): void {
+ this.semanticLayer.setCoordinator(undefined);
+ try {
+ this.semanticLayer.setSemantic(parameter, value, "override");
+ } finally {
+ this.semanticLayer.setCoordinator(this);
+ }
}
/**
@@ -62,54 +210,110 @@ export class ParameterCoordinator {
*/
clearConflictLog(): void {
this.conflictLog = [];
+ this.activeHeldQueueConflicts.clear();
+ this.nextHeldQueueConflicts.clear();
}
- private resolveParameter(parameter: string, writes: QueuedWrite[]): void {
- const overrides = writes.filter((w) => w.blendMode === "override");
- const adds = writes.filter((w) => w.blendMode === "add");
+ private resolveParameter(
+ parameter: string,
+ writes: QueuedWrite[],
+ heldQueueConflicts: Set,
+ ): void {
+ // Single pass: pick the highest-priority override and sum every add.
+ // (filter/filter/reduce/reduce allocated four arrays per parameter per frame.)
+ let queuedWinner: QueuedWrite | null = null;
+ let winnerIsHeld = false;
+ let addSum = 0;
+ let hasAdd = false;
- // Resolve override conflicts: lowest priority number wins (MANUAL=1 is highest)
- let finalValue = 0;
- let hasOverride = false;
- let winner: QueuedWrite | null = null;
+ for (const write of writes) {
+ if (write.blendMode === "override") {
+ // Lower priority number wins; on a tie keep the first one queued.
+ if (queuedWinner === null || write.priority < queuedWinner.priority) {
+ queuedWinner = write;
+ }
+ } else {
+ addSum += write.value;
+ hasAdd = true;
+ }
+ }
- if (overrides.length > 0) {
- winner = overrides.reduce((a, b) => (a.priority <= b.priority ? a : b));
- finalValue = winner.value;
- hasOverride = true;
+ // Preserve queued-vs-queued diagnostics even when a hold eventually wins.
+ if (queuedWinner) {
+ for (const write of writes) {
+ if (write !== queuedWinner && write.blendMode === "override") {
+ this.logConflict(parameter, queuedWinner, write);
+ }
+ }
+ }
- // Log conflicts from other override sources
- for (const w of overrides) {
- if (w !== winner) {
- this.logConflict(parameter, winner, w);
+ let winner: QueuedWrite | HeldWrite | null = queuedWinner;
+ const held = this.held.get(parameter);
+ if (held) {
+ for (const write of held.values()) {
+ if (write.blendMode === "add") {
+ addSum += write.value;
+ hasAdd = true;
+ } else if (
+ winner === null ||
+ write.priority < winner.priority ||
+ (write.priority === winner.priority && !winnerIsHeld)
+ ) {
+ // On a tie an established hold takes precedence over a queued write.
+ winner = write;
+ winnerIsHeld = true;
}
}
}
- // Sum all add outputs (adds don't conflict, they accumulate)
- if (adds.length > 0) {
- const sum = adds.reduce((s, w) => s + w.value, 0);
- if (hasOverride) {
- finalValue += sum;
+ // Held-vs-queued conflicts are useful diagnostics, but the same systems
+ // may compete on every frame. Report each pairing once until it stops.
+ if (winner && held) {
+ if (winnerIsHeld) {
+ for (const write of writes) {
+ if (write.blendMode === "override" && write.source !== winner.source) {
+ this.logHeldQueueConflict(
+ parameter,
+ winner,
+ write,
+ heldQueueConflicts,
+ );
+ }
+ }
} else {
- finalValue = sum;
+ for (const write of held.values()) {
+ if (write.blendMode === "override" && write.source !== winner.source) {
+ this.logHeldQueueConflict(
+ parameter,
+ winner,
+ write,
+ heldQueueConflicts,
+ );
+ }
+ }
}
}
+ const current = this.semanticLayer.getSemantic(parameter) ?? 0;
+ this.applyAbsolute(parameter, (winner?.value ?? current) + (hasAdd ? addSum : 0));
+ }
- // Temporarily detach coordinator to prevent recursive queuing.
- // setSemantic would otherwise re-queue through the coordinator.
- this.semanticLayer.setCoordinator(undefined);
- try {
- this.semanticLayer.setSemantic(parameter, finalValue, hasOverride ? "override" : "add");
- } finally {
- this.semanticLayer.setCoordinator(this);
+ private logHeldQueueConflict(
+ parameter: string,
+ winner: { value: number; source: string },
+ loser: { value: number; source: string },
+ currentConflicts: Set,
+ ): void {
+ const key = JSON.stringify([parameter, winner.source, loser.source]);
+ currentConflicts.add(key);
+ if (!this.activeHeldQueueConflicts.has(key)) {
+ this.logConflict(parameter, winner, loser);
}
}
private logConflict(
parameter: string,
- winner: QueuedWrite,
- loser: QueuedWrite,
+ winner: { value: number; source: string },
+ loser: { value: number; source: string },
): void {
this.conflictLog.push({
timestamp: Date.now(),
diff --git a/packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts b/packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts
index f1ad8e0..e4c991b 100644
--- a/packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts
+++ b/packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts
@@ -115,6 +115,35 @@ describe("EmotionTimeline", () => {
});
describe("interpolation", () => {
+ it("retains a direct transition target until the next emotion starts", () => {
+ const ctx = createMockContext();
+ ctx.motionLayerSystem = undefined;
+ const holdSemantic = vi.fn();
+ const releaseSemantic = vi.fn();
+ Object.assign(ctx.semanticLayer!, { holdSemantic, releaseSemantic });
+ const timeline = new EmotionTimeline(ctx, {
+ defaultDuration: 100,
+ minDuration: 0,
+ defaultEasing: "linear",
+ });
+ timeline.registerEmotion("happy", { parameters: { mouthSmile: 0.6 } });
+ timeline.registerEmotion("neutral", { parameters: { mouthSmile: 0 } });
+
+ timeline.transitionTo("happy");
+ vi.advanceTimersByTime(100);
+ timeline.update();
+ expect(holdSemantic).toHaveBeenCalledWith("mouthSmile", 0.6, "emotion", 3);
+
+ timeline.transitionTo("neutral");
+ expect(releaseSemantic).toHaveBeenCalledWith("mouthSmile", "emotion");
+ vi.advanceTimersByTime(100);
+ timeline.update();
+ expect(holdSemantic).toHaveBeenLastCalledWith("mouthSmile", 0, "emotion", 3);
+
+ timeline.destroy();
+ expect(releaseSemantic).toHaveBeenCalledTimes(2);
+ });
+
it("interpolates parameter values during transition", () => {
const ctx = createMockContext();
const timeline = new EmotionTimeline(ctx, {
diff --git a/packages/live2d/src/runtime/emotion/timeline.ts b/packages/live2d/src/runtime/emotion/timeline.ts
index aab543a..e74cdbb 100644
--- a/packages/live2d/src/runtime/emotion/timeline.ts
+++ b/packages/live2d/src/runtime/emotion/timeline.ts
@@ -17,6 +17,7 @@ export class EmotionTimeline {
private idleTimer: ReturnType | null = null;
private currentFilterHandle: string | null = null;
private currentParameters = new Map();
+ private heldParameters = new Set();
constructor(
context: EmotionTimelineContext,
@@ -82,6 +83,15 @@ export class EmotionTimeline {
const fromParameters = this.captureCurrentParameters();
const toParameters = profile.parameters;
+ // A completed direct transition owns its final values until another
+ // transition starts. Release those values before queueing the new curve.
+ if (!this.context.motionLayerSystem) {
+ for (const param of this.heldParameters) {
+ this.context.semanticLayer?.releaseSemantic(param, "emotion");
+ }
+ this.heldParameters.clear();
+ }
+
this.transition = {
fromEmotion: this.currentEmotion,
toEmotion: emotion,
@@ -202,6 +212,10 @@ export class EmotionTimeline {
this.context.filterPipeline.remove(this.currentFilterHandle);
this.currentFilterHandle = null;
}
+ for (const param of this.heldParameters) {
+ this.context.semanticLayer?.releaseSemantic(param, "emotion");
+ }
+ this.heldParameters.clear();
}
private finishTransition(): void {
@@ -212,6 +226,16 @@ export class EmotionTimeline {
this.currentEmotion = toEmotion;
this.transition = null;
+ // Motion layers keep their expression track active. In direct mode there
+ // is no track, so retain the terminal pose across engine frame restores.
+ if (!this.context.motionLayerSystem && this.context.semanticLayer) {
+ for (const [param, value] of this.currentParameters) {
+ if (!this.context.semanticLayer.hasSemantic(param)) continue;
+ this.context.semanticLayer.holdSemantic(param, value, "emotion", 3);
+ this.heldParameters.add(param);
+ }
+ }
+
// Apply filter preset for the new emotion
this.applyFilterForEmotion(profile);
diff --git a/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts
new file mode 100644
index 0000000..a42512d
--- /dev/null
+++ b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it, vi } from "vitest";
+import { SemanticParameterLayer } from "../../semantic";
+import { ProceduralAnimator } from "../animator";
+import { MutableParameterSet } from "../parameter-set";
+import { ProceduralAnimationSystem } from "../procedural-animation-system";
+
+describe("ProceduralAnimator", () => {
+ it("starts from the last rendered value after the engine restores its baseline", () => {
+ const layer = new SemanticParameterLayer();
+ vi.spyOn(layer, "getSemantic").mockReturnValue(0);
+ vi.spyOn(layer, "getRenderedSemantic").mockReturnValue(20);
+ const animator = new ProceduralAnimator(layer);
+ const params = new MutableParameterSet();
+
+ void animator.animate({ target: "angleX", to: 30, duration: 100, easing: (t) => t });
+ animator.update(50, params);
+
+ expect(params.get("angleX")?.value).toBe(25);
+ });
+
+ it("holds the terminal target and releases it when another animation starts", () => {
+ const layer = new SemanticParameterLayer();
+ vi.spyOn(layer, "getRenderedSemantic").mockReturnValue(0);
+ const holdSemantic = vi.spyOn(layer, "holdSemantic").mockImplementation(() => {});
+ const releaseSemantic = vi.spyOn(layer, "releaseSemantic").mockImplementation(() => {});
+ const animator = new ProceduralAnimator(layer);
+ const params = new MutableParameterSet();
+
+ void animator.animate({ target: "angleX", to: 20, duration: 100, easing: (t) => t });
+ animator.update(100, params);
+ expect(params.get("angleX")?.value).toBe(20);
+ expect(holdSemantic).toHaveBeenCalledWith("angleX", 20, "animator", 5);
+
+ void animator.animate({ target: "angleX", to: 30, duration: 100, easing: (t) => t });
+ expect(releaseSemantic).toHaveBeenCalledWith("angleX", "animator");
+ animator.update(50, params);
+ expect(params.get("angleX")?.value).toBe(15);
+
+ animator.update(50, params);
+ expect(holdSemantic).toHaveBeenLastCalledWith("angleX", 30, "animator", 5);
+ animator.releaseHeldTargets();
+ expect(releaseSemantic).toHaveBeenCalledTimes(2);
+ });
+
+ it("lets a newer short animation keep its target after an older long one would finish", async () => {
+ const layer = new SemanticParameterLayer();
+ vi.spyOn(layer, "getRenderedSemantic").mockReturnValue(0);
+ const holdSemantic = vi.spyOn(layer, "holdSemantic").mockImplementation(() => {});
+ const animator = new ProceduralAnimator(layer);
+ const params = new MutableParameterSet();
+
+ const older = animator.animate({ target: "angleX", to: 20, duration: 300, easing: (t) => t });
+ animator.update(50, params);
+ const newer = animator.animate({ target: "angleX", to: 5, duration: 50, easing: (t) => t });
+
+ params.clear();
+ animator.update(50, params);
+ expect(params.get("angleX")?.value).toBe(5);
+ expect(holdSemantic).toHaveBeenLastCalledWith("angleX", 5, "animator", 5);
+
+ params.clear();
+ animator.update(250, params);
+ expect(params.get("angleX")).toBeUndefined();
+ expect(holdSemantic).toHaveBeenCalledTimes(1);
+ await expect(Promise.all([older, newer])).resolves.toEqual([undefined, undefined]);
+ });
+
+ it("keeps animations for other parameters running when one target is replaced", () => {
+ const layer = new SemanticParameterLayer();
+ vi.spyOn(layer, "getRenderedSemantic").mockReturnValue(0);
+ vi.spyOn(layer, "holdSemantic").mockImplementation(() => {});
+ const animator = new ProceduralAnimator(layer);
+ const params = new MutableParameterSet();
+
+ void animator.animate({ target: "angleX", to: 20, duration: 100, easing: (t) => t });
+ void animator.animate({ target: "angleY", to: 10, duration: 100, easing: (t) => t });
+ void animator.animate({ target: "angleX", to: 5, duration: 100, easing: (t) => t });
+ animator.update(50, params);
+
+ expect(params.get("angleX")?.value).toBe(2.5);
+ expect(params.get("angleY")?.value).toBe(5);
+ });
+
+ it("settles active animations and releases terminal holds when detached", async () => {
+ const layer = new SemanticParameterLayer();
+ vi.spyOn(layer, "getRenderedSemantic").mockReturnValue(0);
+ vi.spyOn(layer, "holdSemantic").mockImplementation(() => {});
+ const releaseSemantic = vi.spyOn(layer, "releaseSemantic").mockImplementation(() => {});
+ const system = new ProceduralAnimationSystem(layer, { enabled: false });
+ const animator = system.getAnimator();
+ void animator.animate({ target: "angleY", to: 10, duration: 1 });
+ animator.update(1, new MutableParameterSet());
+ let settled = false;
+ void animator.animate({ target: "angleX", to: 20, duration: 1000 }).then(() => {
+ settled = true;
+ });
+
+ system.detach();
+ await Promise.resolve();
+
+ expect(settled).toBe(true);
+ expect(releaseSemantic).toHaveBeenCalledWith("angleY", "animator");
+ });
+});
diff --git a/packages/live2d/src/runtime/procedural/animator.ts b/packages/live2d/src/runtime/procedural/animator.ts
index 1fb66b5..1e632a7 100644
--- a/packages/live2d/src/runtime/procedural/animator.ts
+++ b/packages/live2d/src/runtime/procedural/animator.ts
@@ -10,7 +10,7 @@ interface ActiveAnimation {
duration: number;
elapsed: number;
easing: EasingFunction;
- onComplete?: () => void;
+ settle?: () => void;
}
export class ProceduralAnimator implements ProceduralModule {
@@ -18,13 +18,27 @@ export class ProceduralAnimator implements ProceduralModule {
enabled = true;
private animations: ActiveAnimation[] = [];
private semanticLayer: SemanticParameterLayer;
+ private heldTargets = new Set();
constructor(semanticLayer: SemanticParameterLayer) {
this.semanticLayer = semanticLayer;
}
animate(options: AnimationOptions): Promise {
- const currentValue = this.semanticLayer.getSemantic(options.target) ?? 0;
+ const currentValue = this.semanticLayer.getRenderedSemantic(options.target) ?? 0;
+ if (this.heldTargets.delete(options.target)) {
+ this.semanticLayer.releaseSemantic(options.target, "animator");
+ }
+ // A parameter has one animation owner. Settle replaced animations so their
+ // promises do not remain pending, and prevent them from writing or holding
+ // an older target after this animation takes over.
+ for (let index = this.animations.length - 1; index >= 0; index--) {
+ const active = this.animations[index];
+ if (active.target === options.target) {
+ this.animations.splice(index, 1);
+ active.settle?.();
+ }
+ }
const easing =
typeof options.easing === "string"
? getEasing(options.easing)
@@ -38,7 +52,7 @@ export class ProceduralAnimator implements ProceduralModule {
duration: options.duration,
elapsed: 0,
easing,
- onComplete: resolve,
+ settle: resolve,
});
});
}
@@ -64,7 +78,29 @@ export class ProceduralAnimator implements ProceduralModule {
if (index >= 0) {
this.animations.splice(index, 1);
}
- anim.onComplete?.();
}
+
+ // A completed animation should leave its target visible after the engine
+ // restores the frame baseline.
+ for (const anim of completed) {
+ this.semanticLayer.holdSemantic(anim.target, anim.to, "animator", 5);
+ this.heldTargets.add(anim.target);
+ anim.settle?.();
+ }
+ }
+
+ releaseHeldTargets(): void {
+ for (const target of this.heldTargets) {
+ this.semanticLayer.releaseSemantic(target, "animator");
+ }
+ this.heldTargets.clear();
+ }
+
+ stopAll(): void {
+ this.releaseHeldTargets();
+ for (const animation of this.animations) {
+ animation.settle?.();
+ }
+ this.animations = [];
}
}
diff --git a/packages/live2d/src/runtime/procedural/procedural-animation-system.ts b/packages/live2d/src/runtime/procedural/procedural-animation-system.ts
index 93e41f8..8102c27 100644
--- a/packages/live2d/src/runtime/procedural/procedural-animation-system.ts
+++ b/packages/live2d/src/runtime/procedural/procedural-animation-system.ts
@@ -87,6 +87,7 @@ export class ProceduralAnimationSystem {
ticker.remove(this.tickerCallback);
}
this.tickerCallback = undefined;
+ this.animator.stopAll();
this.modules = [];
this.parameterSet.clear();
}
diff --git a/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts b/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts
index 86d6c8e..ca0a72d 100644
--- a/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts
+++ b/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts
@@ -1,4 +1,6 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
+import type { ParameterCoordinator } from "../../controller/coordinator";
+import { SystemPriority } from "../../controller/types";
import { SemanticParameterLayer } from "../semantic-parameter-layer";
// Mock Cubism 2 (Legacy) core model
@@ -176,6 +178,54 @@ describe("SemanticParameterLayer", () => {
});
});
+ describe("holdSemantic", () => {
+ it("delegates to the coordinator", () => {
+ const layer = new SemanticParameterLayer();
+ const core = createCubism4MockModel(["PARAM_ANGLE_X"]);
+ layer.detectFromModel(wrapModel(core));
+
+ const holdWrite = vi.fn();
+ const releaseOverride = vi.fn();
+ layer.setCoordinator({
+ holdWrite,
+ releaseOverride,
+ } as unknown as ParameterCoordinator);
+
+ layer.holdSemantic("angleX", 5, "manual", SystemPriority.MANUAL);
+ expect(holdWrite).toHaveBeenCalledWith(
+ "angleX",
+ 5,
+ "override",
+ "manual",
+ SystemPriority.MANUAL,
+ );
+
+ layer.releaseSemantic("angleX", "manual");
+ expect(releaseOverride).toHaveBeenCalledWith("angleX", "manual");
+ });
+
+ it("requires a coordinator for persistent holds", () => {
+ const layer = new SemanticParameterLayer();
+ const core = createCubism4MockModel(["PARAM_ANGLE_X"]);
+ layer.detectFromModel(wrapModel(core));
+
+ expect(() => layer.holdSemantic("angleX", 100, "manual", SystemPriority.MANUAL))
+ .toThrow("holdSemantic requires a coordinator");
+ expect(layer.getSemantic("angleX")).toBe(0);
+ });
+ });
+
+ it("clears the rendered snapshot when a different model is detected", () => {
+ const layer = new SemanticParameterLayer();
+ layer.detectFromModel(wrapModel(createCubism4MockModel(["PARAM_ANGLE_X"])));
+ layer.setSemantic("angleX", 12);
+ layer.captureRenderedValues();
+ expect(layer.getRenderedSemantic("angleX")).toBe(12);
+
+ layer.detectFromModel(wrapModel(createCubism4MockModel(["PARAM_ANGLE_X"])));
+ expect(layer.getRenderedSemantic("angleX")).toBe(0);
+ });
+
describe("registerSemantic", () => {
it("adds custom mapping before detection", () => {
const layer = new SemanticParameterLayer();
diff --git a/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts b/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts
index 41d5403..6e1c813 100644
--- a/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts
+++ b/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts
@@ -21,6 +21,7 @@ export class SemanticParameterLayer {
};
private sourceModel: object | null = null;
private coordinator?: ParameterCoordinator;
+ private renderedValues = new Map();
/**
* Register a custom semantic mapping before model detection.
@@ -48,6 +49,7 @@ export class SemanticParameterLayer {
}
this.resolved.clear();
+ this.renderedValues.clear();
const detected = new Map();
const missing: SemanticName[] = [];
const notApplicable: SemanticName[] = [];
@@ -101,6 +103,26 @@ export class SemanticParameterLayer {
return this.accessor.getValue(param.index);
}
+ /** Snapshot the values that the engine will render, before it restores its baseline. */
+ captureRenderedValues(): void {
+ for (const name of this.resolved.keys()) {
+ const value = this.getSemantic(name);
+ if (value !== undefined) this.renderedValues.set(name, value);
+ }
+ }
+
+ getRenderedSemantic(name: SemanticName): number | undefined {
+ return this.renderedValues.get(name) ?? this.getSemantic(name);
+ }
+
+ hasHeldSemantic(name: SemanticName, source: string): boolean {
+ return this.coordinator?.hasHeldWrite(name, source) ?? false;
+ }
+
+ getHeldSemantic(name: SemanticName, source: string): number | undefined {
+ return this.coordinator?.getHeldValue(name, source);
+ }
+
/**
* Set the value of a semantic parameter.
* blendMode: 'override' replaces the value, 'add' adds to the current value.
@@ -143,6 +165,33 @@ export class SemanticParameterLayer {
this.accessor.setValue(param.index, targetValue);
}
+ /**
+ * Hold an override or additive contribution until it is replaced or released.
+ * Requires a coordinator, which re-applies the value on every engine frame.
+ */
+ holdSemantic(
+ name: SemanticName,
+ value: number,
+ source: string,
+ priority: SystemPriority,
+ blendMode: BlendMode = "override",
+ ): void {
+ const param = this.resolved.get(name);
+ if (!param || !this.accessor) return;
+
+ if (!this.coordinator) {
+ throw new Error(
+ "SemanticParameterLayer: holdSemantic requires a coordinator",
+ );
+ }
+ this.coordinator.holdWrite(name, value, blendMode, source, priority);
+ }
+
+ /** Release a held semantic parameter so the engine takes it back. */
+ releaseSemantic(name: SemanticName, source?: string): void {
+ this.coordinator?.releaseOverride(name, source);
+ }
+
/**
* Get the capability profile from the last detection.
*/