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. */