From 8daccf742c26bb9a940ab320dfba27f9a3f42dba Mon Sep 17 00:00:00 2001 From: Enterpr1se0 Date: Mon, 21 Sep 2026 15:51:38 +0800 Subject: [PATCH 1/8] fix: stop additive parameter writes from accumulating across frames The engine saves the current parameter values as its baseline at the start of every frame and restores them at the end, so a write made after `internalModel.update()` is baked into the next frame's baseline. `add` writes are relative to the current value, so every frame stacked on top of the previous frame's own result: the parameter reached its limit within a few frames and stayed there (head pinned at +/-30, `breath` pinned at 1). - apply `add` as `engine baseline + contribution` instead of `current value + contribution` - release the contribution once a writer stops - `reset()` drops pending writes and bookkeeping on a model switch - `override` semantics unchanged Also drops the per-parameter `filter()/filter()/reduce()/reduce()` allocations. --- .../controller/__tests__/coordinator.test.ts | 152 +++++++++++++++++ .../src/runtime/controller/controller.ts | 3 + .../src/runtime/controller/coordinator.ts | 156 +++++++++++++++--- 3 files changed, 289 insertions(+), 22 deletions(-) diff --git a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts index e75cba4..6c083cc 100644 --- a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts +++ b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts @@ -92,6 +92,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); @@ -184,4 +195,145 @@ describe("ParameterCoordinator", () => { expect(accessor.setValue).toHaveBeenCalledWith(1, 10); }); }); + + describe("engine parameter lifecycle", () => { + /** + * Stateful mock: values are really stored, and each frame reproduces the + * engine's parameter lifecycle + * (Cubism4InternalModel.update: updateMotions → saveParameters → ... → loadParameters). + */ + function createEngineRig( + options: { absorb?: boolean; engineWrite?: Record } = {}, + ) { + const ids = ["PARAM_ANGLE_X", "PARAM_BREATH"]; + const values = new Float32Array(ids.length); + const saved = new Float32Array(ids.length); + const minimums = new Float32Array([-30, 0]); + const maximums = new Float32Array([30, 1]); + const defaults = new Float32Array([0, 0]); + + 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: its own writes → saveParameters → ... → loadParameters. */ + const engineFrame = () => { + for (const [id, value] of Object.entries(options.engineWrite ?? {})) { + values[ids.indexOf(id)] = value; + } + if (options.absorb === false) { + // An engine that rebuilds its parameters from its own state every frame. + values.set(defaults); + return; + } + saved.set(values); + values.set(saved); + }; + + /** One plugin frame: apply the previous queue, then queue the next write. */ + const frame = (addValue: number | null) => { + engineFrame(); + coordinator.flush(); + if (addValue !== null) { + coordinator.queueWrite( + "angleX", + addValue, + "add", + "procedural", + SystemPriority.PROCEDURAL, + ); + } + }; + + return { coordinator, frame, engineFrame, angleX: () => values[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 relative to the engine baseline, not 15 * 20 clamped to 30. + expect(rig.angleX()).toBe(15); + }); + + it("stays correct when the engine resets parameters every frame", () => { + const rig = createEngineRig({ absorb: false }); + for (let i = 0; i < 20; i++) rig.frame(15); + + expect(rig.angleX()).toBe(15); + }); + + 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); + }); + + 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 released on the frame after, since nothing writes it anymore. + 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("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.engineFrame(); + rig.coordinator.queueWrite("angleX", 5, "override", "emotion", SystemPriority.EMOTION); + rig.coordinator.flush(); + expect(rig.angleX()).toBe(5); + + rig.engineFrame(); + rig.coordinator.flush(); + expect(rig.angleX()).toBe(5); + }); + + it("reset() drops pending writes and tracked contributions", () => { + const rig = createEngineRig(); + for (let i = 0; i < 5; i++) rig.frame(15); + rig.coordinator.queueWrite("angleX", 25, "add", "procedural", SystemPriority.PROCEDURAL); + + rig.coordinator.reset(); + const callsBefore = rig.setValueCalls.length; + rig.engineFrame(); + rig.coordinator.flush(); + + expect(rig.setValueCalls.length).toBe(callsBefore); + }); + }); }); diff --git a/packages/live2d/src/runtime/controller/controller.ts b/packages/live2d/src/runtime/controller/controller.ts index 0b9b698..ba77207 100644 --- a/packages/live2d/src/runtime/controller/controller.ts +++ b/packages/live2d/src/runtime/controller/controller.ts @@ -58,6 +58,9 @@ export class Live2dRuntimeController { // 1. Semantic parameter detection this.semanticLayer.detectFromModel(model); + // Drop anything queued against the previous model, and the add + // contributions that describe its parameters. + this.coordinator.reset(); // 2. Motion layer system if (this.config.motionLayers?.enabled !== false) { diff --git a/packages/live2d/src/runtime/controller/coordinator.ts b/packages/live2d/src/runtime/controller/coordinator.ts index 595dbcf..2211d4d 100644 --- a/packages/live2d/src/runtime/controller/coordinator.ts +++ b/packages/live2d/src/runtime/controller/coordinator.ts @@ -10,11 +10,34 @@ interface QueuedWrite { priority: SystemPriority; } +/** + * Bookkeeping for `add` writes, which are relative to the engine baseline. + * + * The engine saves the current parameter values as its baseline every frame + * (`CubismModel.saveParameters` / `saveParam`) and restores them at the end of + * the same frame, so anything written after the engine update is baked into the + * next frame's baseline. Without tracking our own contribution, every frame + * would add on top of the previous frame's result and the parameter would drift + * to its limit within a few frames. + * + * Note: while a motion is fading in the engine blends on top of our leftover + * (`values = values * (1 - weight) + motion * weight`), so the recovered baseline + * is only approximate for the duration of that fade. Once the fade completes + * (weight 1) the motion overwrites the parameter and we take the engine value. + */ +interface AppliedAdd { + /** Contribution applied last frame, relative to the engine baseline. */ + contribution: number; + /** Absolute value actually written, read back after writing. */ + written: number; +} + export class ParameterCoordinator { private queue = new Map(); private conflictLog: ConflictEntry[] = []; private semanticLayer: SemanticParameterLayer; private maxLogSize: number; + private appliedAdds = new Map(); constructor( semanticLayer: SemanticParameterLayer, @@ -24,6 +47,17 @@ export class ParameterCoordinator { this.maxLogSize = options.maxLogSize ?? 50; } + /** + * Drop pending writes and add bookkeeping. + * + * Must be called when the model changes: pending writes were queued against + * the previous model, and the tracked contributions describe its parameters. + */ + reset(): void { + this.queue.clear(); + this.appliedAdds.clear(); + } + /** * Queue a parameter write. Writes are not applied until flush() is called. */ @@ -44,12 +78,49 @@ export class ParameterCoordinator { * Should be called once per frame after all subsystems have queued writes. */ flush(): void { + this.releaseStaleContributions(); + for (const [parameter, writes] of this.queue) { this.resolveParameter(parameter, writes); } this.queue.clear(); } + /** + * Release `add` contributions of parameters that were not written this frame. + * + * Because the engine absorbs our writes into its baseline (see + * {@link AppliedAdd}), a contribution does not disappear when its writer stops: + * it has to be subtracted explicitly. Parameters the engine rewrote this frame + * are left alone, since the engine already took them over. + */ + private releaseStaleContributions(): void { + for (const [parameter, record] of this.appliedAdds) { + if (this.queue.has(parameter)) continue; + + this.appliedAdds.delete(parameter); + if (record.contribution === 0) continue; + + const current = this.semanticLayer.getSemantic(parameter); + if (current === undefined || record.written !== current) continue; + + this.applyAbsolute(parameter, current - record.contribution); + } + } + + /** + * 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); + } + } + /** * Get the current conflict log. */ @@ -65,44 +136,85 @@ export class ParameterCoordinator { } private resolveParameter(parameter: string, writes: QueuedWrite[]): void { - const overrides = writes.filter((w) => w.blendMode === "override"); - const adds = writes.filter((w) => w.blendMode === "add"); + // Single pass: pick the highest-priority override and sum every add. + // (filter/filter/reduce/reduce allocated four arrays per parameter per frame.) + let winner: QueuedWrite | null = null; + let addSum = 0; + let hasAdd = false; + + for (const write of writes) { + if (write.blendMode === "override") { + // Lower priority number wins; on a tie keep the first one queued + // (same as the original `reduce((a, b) => (a.priority <= b.priority ? a : b))`). + if (winner === null || write.priority < winner.priority) { + winner = write; + } + } else { + addSum += write.value; + hasAdd = true; + } + } // Resolve override conflicts: lowest priority number wins (MANUAL=1 is highest) let finalValue = 0; let hasOverride = false; - let winner: QueuedWrite | null = null; - if (overrides.length > 0) { - winner = overrides.reduce((a, b) => (a.priority <= b.priority ? a : b)); - finalValue = winner.value; + if (winner !== null) { hasOverride = true; + finalValue = winner.value; // Log conflicts from other override sources - for (const w of overrides) { - if (w !== winner) { - this.logConflict(parameter, winner, w); + for (const write of writes) { + if (write !== winner && write.blendMode === "override") { + this.logConflict(parameter, winner, write); } } } // 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; - } else { - finalValue = sum; + if (hasAdd) { + finalValue = hasOverride ? finalValue + addSum : addSum; + } + + // The engine absorbs our writes into its baseline, so an `add` cannot be + // applied on top of the current value: subtract our own previous + // contribution first to recover the engine baseline. + const current = this.semanticLayer.getSemantic(parameter) ?? 0; + let engineBaseline = current; + + if (hasOverride) { + // `override` is absolute, it takes the parameter over; stop tracking adds. + this.appliedAdds.delete(parameter); + } else { + const previous = this.appliedAdds.get(parameter); + // `written === current` means the engine did not touch this parameter + // this frame, so our previous write is still in place. + if (previous && previous.written === current) { + engineBaseline = current - previous.contribution; } } - // 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); + this.applyAbsolute( + parameter, + hasOverride ? finalValue : engineBaseline + finalValue, + ); + + if (!hasOverride) { + // Read the value back: `setSemantic` clamps it and Float32Array storage + // rounds it, so only the stored value can be compared exactly next frame. + const written = this.semanticLayer.getSemantic(parameter); + if (written !== undefined) { + const record = this.appliedAdds.get(parameter); + if (record) { + record.contribution = written - engineBaseline; + record.written = written; + } else { + this.appliedAdds.set(parameter, { + contribution: written - engineBaseline, + written, + }); + } + } } } From 988ca0e8d0f61af03757ffd652d69e1be2ba3160 Mon Sep 17 00:00:00 2001 From: Enterpr1se0 Date: Thu, 24 Sep 2026 16:41:48 +0800 Subject: [PATCH 2/8] refactor: apply parameter writes from the engine's beforeModelUpdate event The engine saves the parameter values as a baseline every frame and restores that baseline at the end of the same frame, so a write made after `internalModel.update()` became part of the next frame's baseline: an `add` write stacked on top of its own previous result until the parameter reached its limit. The previous approach recovered the baseline by subtracting our own previous contribution, which can leave an offset behind - a fading motion blends on top of our leftover, so the recovered value is only approximate for the duration of that fade. Apply the writes from the engine's `beforeModelUpdate` event instead: it runs after the baseline was saved and before the model is rendered with those parameters, so a write is visible for exactly one frame and the engine drops it afterwards. No contribution bookkeeping or value guessing is needed. - `add` is applied on top of the engine's current value - `override` keeps its conflict resolution, now resolved in the same single pass - drops `AppliedAdd`, `appliedAdds`, `releaseStaleContributions` and the write-back comparison Note: a one-shot `override` (the DevTools parameter slider) is now applied for the frame it was queued in; a caller that needs it to hold has to queue it every frame. --- .../controller/__tests__/coordinator.test.ts | 131 ++++++++++++----- .../src/runtime/controller/controller.ts | 42 +++--- .../src/runtime/controller/coordinator.ts | 137 ++++-------------- 3 files changed, 147 insertions(+), 163 deletions(-) diff --git a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts index 6c083cc..b73b47e 100644 --- a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts +++ b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts @@ -10,9 +10,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, @@ -173,7 +177,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(); @@ -198,19 +202,29 @@ describe("ParameterCoordinator", () => { describe("engine parameter lifecycle", () => { /** - * Stateful mock: values are really stored, and each frame reproduces the - * engine's parameter lifecycle - * (Cubism4InternalModel.update: updateMotions → saveParameters → ... → loadParameters). + * 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: { absorb?: boolean; engineWrite?: Record } = {}, + 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 saved = 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 defaults = new Float32Array([0, 0]); const layer = new SemanticParameterLayer(); (layer as unknown as Record).resolved = new Map([ @@ -232,24 +246,35 @@ describe("ParameterCoordinator", () => { const coordinator = new ParameterCoordinator(layer); layer.setCoordinator(coordinator); - /** One engine frame: its own writes → saveParameters → ... → loadParameters. */ + /** 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; } - if (options.absorb === false) { - // An engine that rebuilds its parameters from its own state every frame. - values.set(defaults); - return; + + // 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), + ); } - saved.set(values); - values.set(saved); + + // beforeModelUpdate: our writes are visible in this frame only. + coordinator.flush(); + rendered.set(values); + + // loadParameters() + values.set(baseline); }; - /** One plugin frame: apply the previous queue, then queue the next write. */ + /** One plugin frame: run the engine, then queue the next write. */ const frame = (addValue: number | null) => { engineFrame(); - coordinator.flush(); if (addValue !== null) { coordinator.queueWrite( "angleX", @@ -261,22 +286,26 @@ describe("ParameterCoordinator", () => { } }; - return { coordinator, frame, engineFrame, angleX: () => values[0], setValueCalls }; + return { + coordinator, + 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 relative to the engine baseline, not 15 * 20 clamped to 30. - expect(rig.angleX()).toBe(15); - }); - - it("stays correct when the engine resets parameters every frame", () => { - const rig = createEngineRig({ absorb: false }); - 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", () => { @@ -285,6 +314,17 @@ describe("ParameterCoordinator", () => { // 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", () => { @@ -295,7 +335,7 @@ describe("ParameterCoordinator", () => { rig.frame(null); // The add queued by the previous frame is applied once more... expect(rig.angleX()).toBe(15); - // ...and released on the frame after, since nothing writes it anymore. + // ...and gone on the frame after, since the engine restored its baseline. rig.frame(null); expect(rig.angleX()).toBe(0); }); @@ -307,31 +347,56 @@ describe("ParameterCoordinator", () => { 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.engineFrame(); rig.coordinator.queueWrite("angleX", 5, "override", "emotion", SystemPriority.EMOTION); - rig.coordinator.flush(); + 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 one-shot override (the DevTools parameter slider does this) 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. + const rig = createEngineRig(); + rig.coordinator.queueWrite("angleX", 5, "override", "manual", SystemPriority.MANUAL); + rig.engineFrame(); - rig.coordinator.flush(); expect(rig.angleX()).toBe(5); + expect(rig.baselineAngleX()).toBe(0); + + rig.engineFrame(); + expect(rig.angleX()).toBe(0); }); - it("reset() drops pending writes and tracked contributions", () => { + 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(); - rig.coordinator.flush(); expect(rig.setValueCalls.length).toBe(callsBefore); }); diff --git a/packages/live2d/src/runtime/controller/controller.ts b/packages/live2d/src/runtime/controller/controller.ts index ba77207..f29b2eb 100644 --- a/packages/live2d/src/runtime/controller/controller.ts +++ b/packages/live2d/src/runtime/controller/controller.ts @@ -137,20 +137,24 @@ 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(); + internalModel.on("beforeModelUpdate", onBeforeModelUpdate); + this._tickerCallbacks.push(() => { + internalModel.off("beforeModelUpdate", onBeforeModelUpdate); + }); // Attach filter pipeline to model this.filterPipeline.attachTo(model); @@ -279,16 +283,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 2211d4d..fac25d9 100644 --- a/packages/live2d/src/runtime/controller/coordinator.ts +++ b/packages/live2d/src/runtime/controller/coordinator.ts @@ -11,33 +11,26 @@ interface QueuedWrite { } /** - * Bookkeeping for `add` writes, which are relative to the engine baseline. + * Applies the queued parameter writes from inside the engine's own update. * - * The engine saves the current parameter values as its baseline every frame - * (`CubismModel.saveParameters` / `saveParam`) and restores them at the end of - * the same frame, so anything written after the engine update is baked into the - * next frame's baseline. Without tracking our own contribution, every frame - * would add on top of the previous frame's result and the parameter would drift - * to its limit within a few frames. + * 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. * - * Note: while a motion is fading in the engine blends on top of our leftover - * (`values = values * (1 - weight) + motion * weight`), so the recovered baseline - * is only approximate for the duration of that fade. Once the fade completes - * (weight 1) the motion overwrites the parameter and we take the engine value. + * 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. */ -interface AppliedAdd { - /** Contribution applied last frame, relative to the engine baseline. */ - contribution: number; - /** Absolute value actually written, read back after writing. */ - written: number; -} - export class ParameterCoordinator { private queue = new Map(); private conflictLog: ConflictEntry[] = []; private semanticLayer: SemanticParameterLayer; private maxLogSize: number; - private appliedAdds = new Map(); constructor( semanticLayer: SemanticParameterLayer, @@ -48,14 +41,13 @@ export class ParameterCoordinator { } /** - * Drop pending writes and add bookkeeping. + * Drop pending writes. * * Must be called when the model changes: pending writes were queued against - * the previous model, and the tracked contributions describe its parameters. + * the previous model's parameters. */ reset(): void { this.queue.clear(); - this.appliedAdds.clear(); } /** @@ -74,40 +66,17 @@ 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. + * 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 { - this.releaseStaleContributions(); - for (const [parameter, writes] of this.queue) { this.resolveParameter(parameter, writes); } this.queue.clear(); } - /** - * Release `add` contributions of parameters that were not written this frame. - * - * Because the engine absorbs our writes into its baseline (see - * {@link AppliedAdd}), a contribution does not disappear when its writer stops: - * it has to be subtracted explicitly. Parameters the engine rewrote this frame - * are left alone, since the engine already took them over. - */ - private releaseStaleContributions(): void { - for (const [parameter, record] of this.appliedAdds) { - if (this.queue.has(parameter)) continue; - - this.appliedAdds.delete(parameter); - if (record.contribution === 0) continue; - - const current = this.semanticLayer.getSemantic(parameter); - if (current === undefined || record.written !== current) continue; - - this.applyAbsolute(parameter, current - record.contribution); - } - } - /** * Write an absolute value, temporarily detaching the coordinator so that the * write is not re-queued. @@ -144,8 +113,7 @@ export class ParameterCoordinator { for (const write of writes) { if (write.blendMode === "override") { - // Lower priority number wins; on a tie keep the first one queued - // (same as the original `reduce((a, b) => (a.priority <= b.priority ? a : b))`). + // Lower priority number wins; on a tie keep the first one queued. if (winner === null || write.priority < winner.priority) { winner = write; } @@ -155,67 +123,24 @@ export class ParameterCoordinator { } } - // Resolve override conflicts: lowest priority number wins (MANUAL=1 is highest) - let finalValue = 0; - let hasOverride = false; - - if (winner !== null) { - hasOverride = true; - finalValue = winner.value; - - // Log conflicts from other override sources - for (const write of writes) { - if (write !== winner && write.blendMode === "override") { - this.logConflict(parameter, winner, write); - } - } - } - - // Sum all add outputs (adds don't conflict, they accumulate) - if (hasAdd) { - finalValue = hasOverride ? finalValue + addSum : addSum; + if (winner === null) { + // Only relative writes: stack them on the engine's current value. The + // engine drops them when it restores its baseline, so they never + // accumulate across frames. + const current = this.semanticLayer.getSemantic(parameter) ?? 0; + this.applyAbsolute(parameter, current + addSum); + return; } - // The engine absorbs our writes into its baseline, so an `add` cannot be - // applied on top of the current value: subtract our own previous - // contribution first to recover the engine baseline. - const current = this.semanticLayer.getSemantic(parameter) ?? 0; - let engineBaseline = current; - - if (hasOverride) { - // `override` is absolute, it takes the parameter over; stop tracking adds. - this.appliedAdds.delete(parameter); - } else { - const previous = this.appliedAdds.get(parameter); - // `written === current` means the engine did not touch this parameter - // this frame, so our previous write is still in place. - if (previous && previous.written === current) { - engineBaseline = current - previous.contribution; + // Resolve override conflicts: lowest priority number wins (MANUAL=1 is highest) + for (const write of writes) { + if (write !== winner && write.blendMode === "override") { + this.logConflict(parameter, winner, write); } } - this.applyAbsolute( - parameter, - hasOverride ? finalValue : engineBaseline + finalValue, - ); - - if (!hasOverride) { - // Read the value back: `setSemantic` clamps it and Float32Array storage - // rounds it, so only the stored value can be compared exactly next frame. - const written = this.semanticLayer.getSemantic(parameter); - if (written !== undefined) { - const record = this.appliedAdds.get(parameter); - if (record) { - record.contribution = written - engineBaseline; - record.written = written; - } else { - this.appliedAdds.set(parameter, { - contribution: written - engineBaseline, - written, - }); - } - } - } + // Adds don't conflict, they accumulate on top of the winning override. + this.applyAbsolute(parameter, hasAdd ? winner.value + addSum : winner.value); } private logConflict( From 530ae7e2bfa7bb0dbe81cce0418ab0b8a31ae6c7 Mon Sep 17 00:00:00 2001 From: Enterpr1se0 Date: Mon, 28 Sep 2026 14:46:08 +0800 Subject: [PATCH 3/8] feat: hold manual and state parameter overrides across frames A write applied from the engine's `beforeModelUpdate` event only survives the frame it was queued in: the engine restores its own parameter baseline at the end of every frame. That is what stops `add` writes from accumulating, but it also meant a writer that knows its value once - the DevTools parameter slider, an FSM state profile - only took effect for a single frame. `ParameterCoordinator.holdOverride()` re-applies such a value on every flush until `releaseOverride()` (optionally restricted to one source). The DevTools slider holds its value as MANUAL, FSM state profiles hold theirs as FSM and release them on state exit, and `getSemanticParameters()` reports the held value so the panel shows what is actually being applied. A queued write suppressed by a hold is not logged as a conflict: the hold is re-applied every frame, so logging it would flood the conflict log. --- .../Live2dDevTools/Live2dDevTools.ts | 4 +- .../__tests__/Live2dDevTools.test.ts | 6 +- .../behavior/__tests__/behavior-fsm.test.ts | 36 ++++-- packages/live2d/src/runtime/behavior/fsm.ts | 15 +-- .../controller/__tests__/controller.test.ts | 31 +++++ .../controller/__tests__/coordinator.test.ts | 107 +++++++++++++++++- .../src/runtime/controller/controller.ts | 11 +- .../src/runtime/controller/coordinator.ts | 102 +++++++++++++++-- .../semantic-parameter-layer.test.ts | 39 ++++++- .../semantic/semantic-parameter-layer.ts | 29 +++++ 10 files changed, 344 insertions(+), 36 deletions(-) diff --git a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts index f3f0c31..d128b71 100644 --- a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts +++ b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts @@ -279,9 +279,7 @@ 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); } private _sectionHeader( diff --git a/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts b/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts index a1da763..eb8777a 100644 --- a/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts +++ b/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts @@ -82,11 +82,11 @@ 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("filter intensity slider adjusts effect intensity", () => { 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..e6625ad 100644 --- a/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts +++ b/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts @@ -20,6 +20,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(() => ({ @@ -215,15 +217,38 @@ describe("BehaviorFSM", () => { fsm.transitionTo("happy"); - expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith( + expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith( "mouthSmile", 0.6, - "override", "fsm", 2, ); }); + it("keeps add-mode semantic parameters as per-frame writes", () => { + 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!.setSemantic).toHaveBeenCalledWith( + "mouthSmile", + 0.2, + "add", + "fsm", + 2, + ); + expect(ctx.semanticLayer!.holdSemantic).not.toHaveBeenCalled(); + }); + it("applies procedural overrides on state entry", () => { const ctx = createMockContext(); const fsm = new BehaviorFSM(ctx); @@ -280,7 +305,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,12 +321,9 @@ describe("BehaviorFSM", () => { fsm.transitionTo("happy"); fsm.transitionTo("idle"); - expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith( + expect(ctx.semanticLayer!.releaseSemantic).toHaveBeenCalledWith( "mouthSmile", - 0, - "override", "fsm", - 2, ); }); diff --git a/packages/live2d/src/runtime/behavior/fsm.ts b/packages/live2d/src/runtime/behavior/fsm.ts index 60c13e6..e0855b8 100644 --- a/packages/live2d/src/runtime/behavior/fsm.ts +++ b/packages/live2d/src/runtime/behavior/fsm.ts @@ -209,14 +209,11 @@ 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; + if (config.blendMode === "add") { + semanticLayer.setSemantic(name, config.value, "add", "fsm", 2); + } else { + semanticLayer.holdSemantic(name, config.value, "fsm", 2); } } } @@ -271,7 +268,7 @@ export class BehaviorFSM { if (profile.semanticParameters && semanticLayer) { for (const name of Object.keys(profile.semanticParameters)) { - semanticLayer.setSemantic(name, 0, "override", "fsm", 2); + semanticLayer.releaseSemantic(name, "fsm"); } } diff --git a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts index 6cd2522..a2bc247 100644 --- a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts +++ b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts @@ -139,6 +139,37 @@ describe("Live2dRuntimeController", () => { expect(history.length).toBe(10); }); + it("getSemanticParameters reports a held override", () => { + 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); + + // 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: 12 }, + ]); + }); + 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 b73b47e..034279e 100644 --- a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts +++ b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts @@ -374,9 +374,9 @@ describe("ParameterCoordinator", () => { }); it("keeps an override for one frame only unless it is written again", () => { - // A one-shot override (the DevTools parameter slider does this) 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. + // 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); @@ -400,5 +400,106 @@ describe("ParameterCoordinator", () => { 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); + } + + // The hold suppresses the write every frame, so it is not logged as a + // conflict - that would flood the log. + expect(rig.coordinator.getConflictLog()).toEqual([]); + }); + + 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("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 f29b2eb..556126a 100644 --- a/packages/live2d/src/runtime/controller/controller.ts +++ b/packages/live2d/src/runtime/controller/controller.ts @@ -231,12 +231,21 @@ export class Live2dRuntimeController { /** * Get current semantic parameter values for DevTools display. + * + * A held override wins over the engine's value: held values are applied for + * one frame at a time and the engine restores its own baseline afterwards, so + * the raw parameter does not report what the runtime is applying. */ 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.coordinator.getHeldValue(name) ?? + this.semanticLayer.getSemantic(name), + }); } return result; } diff --git a/packages/live2d/src/runtime/controller/coordinator.ts b/packages/live2d/src/runtime/controller/coordinator.ts index fac25d9..12042d1 100644 --- a/packages/live2d/src/runtime/controller/coordinator.ts +++ b/packages/live2d/src/runtime/controller/coordinator.ts @@ -10,6 +10,12 @@ interface QueuedWrite { priority: SystemPriority; } +interface HeldOverride { + value: number; + source: string; + priority: SystemPriority; +} + /** * Applies the queued parameter writes from inside the engine's own update. * @@ -25,9 +31,18 @@ interface QueuedWrite { * 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. + * - `holdOverride()` - an override 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 semanticLayer: SemanticParameterLayer; private maxLogSize: number; @@ -41,13 +56,14 @@ export class ParameterCoordinator { } /** - * Drop pending writes. + * 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(); } /** @@ -65,14 +81,64 @@ export class ParameterCoordinator { this.queue.set(parameter, list); } + /** + * Hold `parameter` at `value` 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. + * + * A lower-priority hold never replaces a higher-priority one (MANUAL=1 is the + * highest); that suppression is logged like any other conflict. + */ + holdOverride( + parameter: string, + value: number, + source: string, + priority: SystemPriority, + ): void { + const existing = this.held.get(parameter); + if (existing && existing.priority < priority) { + this.logConflict(parameter, existing, { value, source }); + return; + } + this.held.set(parameter, { value, source, priority }); + } + + /** + * Release a held override. With `source` given only that source's hold is + * released, so one subsystem cannot drop another's. + */ + releaseOverride(parameter: string, source?: string): void { + const existing = this.held.get(parameter); + if (!existing) return; + if (source !== undefined && existing.source !== source) return; + this.held.delete(parameter); + } + + /** Value a held override currently pins `parameter` to, if any. */ + getHeldValue(parameter: string): number | undefined { + return this.held.get(parameter)?.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 handled = new Set(); for (const [parameter, writes] of this.queue) { this.resolveParameter(parameter, writes); + handled.add(parameter); + } + // A held override whose parameter nobody wrote this frame still has to be + // re-applied: the engine restored its baseline at the end of the last frame. + for (const [parameter, held] of this.held) { + if (!handled.has(parameter)) { + this.applyAbsolute(parameter, held.value); + } } this.queue.clear(); } @@ -123,12 +189,21 @@ export class ParameterCoordinator { } } + const held = this.held.get(parameter); + if (winner === null) { - // Only relative writes: stack them on the engine's current value. The - // engine drops them when it restores its baseline, so they never - // accumulate across frames. - const current = this.semanticLayer.getSemantic(parameter) ?? 0; - this.applyAbsolute(parameter, current + addSum); + if (held === undefined) { + // Only relative writes: stack them on the engine's current value. The + // engine drops them when it restores its baseline, so they never + // accumulate across frames. + const current = this.semanticLayer.getSemantic(parameter) ?? 0; + this.applyAbsolute(parameter, current + addSum); + return; + } + + // A hold with relative writes on top of it: no queued override left to + // resolve a conflict against, and every queued write here is an add. + this.applyAbsolute(parameter, held.value + addSum); return; } @@ -139,14 +214,23 @@ export class ParameterCoordinator { } } + // A held override outranks a queued one unless the queued one has a + // strictly higher priority; on a tie the held value - the established + // state - wins. Queued writes suppressed by a hold are not logged: the hold + // is re-applied every frame, so logging them would flood the conflict log. + const effective = + held !== undefined && held.priority <= winner.priority + ? held.value + : winner.value; + // Adds don't conflict, they accumulate on top of the winning override. - this.applyAbsolute(parameter, hasAdd ? winner.value + addSum : winner.value); + this.applyAbsolute(parameter, hasAdd ? effective + addSum : effective); } 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/semantic/__tests__/semantic-parameter-layer.test.ts b/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts index 86d6c8e..add0052 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,41 @@ describe("SemanticParameterLayer", () => { }); }); + describe("holdSemantic", () => { + it("delegates to the coordinator", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel(["PARAM_ANGLE_X"]); + layer.detectFromModel(wrapModel(core)); + + const holdOverride = vi.fn(); + const releaseOverride = vi.fn(); + layer.setCoordinator({ + holdOverride, + releaseOverride, + } as unknown as ParameterCoordinator); + + layer.holdSemantic("angleX", 5, "manual", SystemPriority.MANUAL); + expect(holdOverride).toHaveBeenCalledWith( + "angleX", + 5, + "manual", + SystemPriority.MANUAL, + ); + + layer.releaseSemantic("angleX", "manual"); + expect(releaseOverride).toHaveBeenCalledWith("angleX", "manual"); + }); + + it("writes and clamps without a coordinator", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel(["PARAM_ANGLE_X"]); + layer.detectFromModel(wrapModel(core)); + + layer.holdSemantic("angleX", 100, "manual", SystemPriority.MANUAL); + expect(layer.getSemantic("angleX")).toBe(30); // clamped to max + }); + }); + 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..b6f6912 100644 --- a/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts +++ b/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts @@ -143,6 +143,35 @@ export class SemanticParameterLayer { this.accessor.setValue(param.index, targetValue); } + /** + * Hold a semantic parameter at a value until it is replaced or released. + * Unlike `setSemantic`, the value is re-applied on every frame, so a writer + * that only knows its value once keeps taking effect. + */ + holdSemantic( + name: SemanticName, + value: number, + source: string, + priority: SystemPriority, + ): void { + const param = this.resolved.get(name); + if (!param || !this.accessor) return; + + if (this.coordinator) { + this.coordinator.holdOverride(name, value, source, priority); + return; + } + + const min = this.accessor.getMin(param.index); + const max = this.accessor.getMax(param.index); + this.accessor.setValue(param.index, Math.max(min, Math.min(max, value))); + } + + /** 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. */ From 313a3c60c55e8d903b05ce7a4c5f1504cc612a5b Mon Sep 17 00:00:00 2001 From: Enterpr1se0 Date: Mon, 28 Sep 2026 14:55:40 +0800 Subject: [PATCH 4/8] refactor: drop the per-frame Set from ParameterCoordinator.flush `flush()` runs on every engine frame. Tracking which parameters were already resolved in a fresh `Set` allocated one object per frame even when nothing is held; the queue itself still holds this frame's parameters at that point, so it doubles as the lookup. --- packages/live2d/src/runtime/controller/coordinator.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/live2d/src/runtime/controller/coordinator.ts b/packages/live2d/src/runtime/controller/coordinator.ts index 12042d1..77e9740 100644 --- a/packages/live2d/src/runtime/controller/coordinator.ts +++ b/packages/live2d/src/runtime/controller/coordinator.ts @@ -128,16 +128,17 @@ export class ParameterCoordinator { * part of that frame's render and are dropped by the engine afterwards. */ flush(): void { - const handled = new Set(); for (const [parameter, writes] of this.queue) { this.resolveParameter(parameter, writes); - handled.add(parameter); } // A held override whose parameter nobody wrote this frame still has to be // re-applied: the engine restored its baseline at the end of the last frame. - for (const [parameter, held] of this.held) { - if (!handled.has(parameter)) { - this.applyAbsolute(parameter, held.value); + // The queue still holds this frame's parameters, so it doubles as the lookup. + if (this.held.size > 0) { + for (const [parameter, held] of this.held) { + if (!this.queue.has(parameter)) { + this.applyAbsolute(parameter, held.value); + } } } this.queue.clear(); From 0eecfeec7ed55a67f6c5fddd0b889fb007b92eaf Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Mon, 28 Sep 2026 16:22:36 +0800 Subject: [PATCH 5/8] fix: preserve parameter state across model frames --- .../Live2dDevTools/Live2dDevTools.ts | 11 +- .../__tests__/Live2dDevTools.test.ts | 31 +++- .../behavior/__tests__/behavior-fsm.test.ts | 57 +++++++- packages/live2d/src/runtime/behavior/fsm.ts | 29 ++-- .../controller/__tests__/controller.test.ts | 10 +- .../controller/__tests__/coordinator.test.ts | 76 ++++++++++ .../src/runtime/controller/controller.ts | 17 ++- .../src/runtime/controller/coordinator.ts | 132 +++++++++++------- .../procedural/__tests__/animator.test.ts | 19 +++ .../live2d/src/runtime/procedural/animator.ts | 2 +- .../semantic-parameter-layer.test.ts | 18 ++- .../semantic/semantic-parameter-layer.ts | 30 +++- 12 files changed, 342 insertions(+), 90 deletions(-) create mode 100644 packages/live2d/src/runtime/procedural/__tests__/animator.test.ts diff --git a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts index d128b71..0394474 100644 --- a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts +++ b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts @@ -280,6 +280,11 @@ export class Live2dDevTools extends UnoLitElement { } private _setParamValue(p: string, v: number): void { 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( @@ -764,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 eb8777a..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; } @@ -89,6 +93,29 @@ describe("Live2dDevTools", () => { 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", () => { const filterPipeline = controller.getFilterPipeline(); const setIntensitySpy = vi.spyOn(filterPipeline, "setIntensity"); @@ -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 e6625ad..3cc1b18 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 { @@ -164,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); @@ -222,10 +254,11 @@ describe("BehaviorFSM", () => { 0.6, "fsm", 2, + "override", ); }); - it("keeps add-mode semantic parameters as per-frame writes", () => { + it("holds add-mode semantic parameters for the state's lifetime", () => { const ctx = createMockContext(); const fsm = new BehaviorFSM(ctx); fsm.registerState({ @@ -239,14 +272,14 @@ describe("BehaviorFSM", () => { fsm.transitionTo("happy"); - expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith( + expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith( "mouthSmile", 0.2, - "add", "fsm", 2, + "add", ); - expect(ctx.semanticLayer!.holdSemantic).not.toHaveBeenCalled(); + expect(ctx.semanticLayer!.setSemantic).not.toHaveBeenCalled(); }); it("applies procedural overrides on state entry", () => { @@ -327,6 +360,22 @@ describe("BehaviorFSM", () => { ); }); + 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"); + }); + 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 e0855b8..9da32a4 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 = {}) { @@ -122,6 +123,7 @@ export class BehaviorFSM { } else if (fromState?.entryProfile) { this.revertProfile(fromState.entryProfile); } + this.releaseActiveSemanticParameters(); // 3. Call enter hook on new state if (toState.onEnter) { @@ -210,11 +212,14 @@ export class BehaviorFSM { if (profile.semanticParameters && semanticLayer) { for (const [name, config] of Object.entries(profile.semanticParameters)) { if (!semanticLayer.hasSemantic(name)) continue; - if (config.blendMode === "add") { - semanticLayer.setSemantic(name, config.value, "add", "fsm", 2); - } else { - semanticLayer.holdSemantic(name, config.value, "fsm", 2); - } + semanticLayer.holdSemantic( + name, + config.value, + "fsm", + 2, + config.blendMode ?? "override", + ); + this.activeSemanticParameters.add(name); } } @@ -245,7 +250,6 @@ export class BehaviorFSM { const { motionLayerSystem, filterPipeline, - semanticLayer, proceduralSystem, } = this.context; @@ -266,12 +270,6 @@ export class BehaviorFSM { } } - if (profile.semanticParameters && semanticLayer) { - for (const name of Object.keys(profile.semanticParameters)) { - semanticLayer.releaseSemantic(name, "fsm"); - } - } - if (profile.proceduralOverrides && proceduralSystem) { for (const moduleName of Object.keys(profile.proceduralOverrides)) { const previousState = this.proceduralModuleStates.get(moduleName); @@ -287,4 +285,11 @@ export class BehaviorFSM { } } } + + private releaseActiveSemanticParameters(): void { + for (const name of this.activeSemanticParameters) { + this.context.semanticLayer?.releaseSemantic(name, "fsm"); + } + this.activeSemanticParameters.clear(); + } } diff --git a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts index a2bc247..a0fe70d 100644 --- a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts +++ b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { Live2dRuntimeController } from "../controller"; import type { BehaviorFSM } from "../../behavior"; +import type { ParameterCoordinator } from "../coordinator"; describe("Live2dRuntimeController", () => { it("creates with default config", () => { @@ -139,7 +140,7 @@ describe("Live2dRuntimeController", () => { expect(history.length).toBe(10); }); - it("getSemanticParameters reports a held override", () => { + it("getSemanticParameters reports the last rendered value after engine restore", () => { const controller = new Live2dRuntimeController(); const values = new Float32Array(1); const coreModel = { @@ -161,12 +162,17 @@ describe("Live2dRuntimeController", () => { 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: 12 }, + { name: "angleX", value: 17 }, ]); }); diff --git a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts index 034279e..20821bc 100644 --- a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts +++ b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts @@ -266,6 +266,7 @@ describe("ParameterCoordinator", () => { // beforeModelUpdate: our writes are visible in this frame only. coordinator.flush(); + layer.captureRenderedValues(); rendered.set(values); // loadParameters() @@ -288,6 +289,7 @@ describe("ParameterCoordinator", () => { return { coordinator, + layer, frame, engineFrame, /** Value the model was last rendered with. */ @@ -436,6 +438,16 @@ describe("ParameterCoordinator", () => { expect(rig.coordinator.getConflictLog()).toEqual([]); }); + 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); @@ -453,6 +465,70 @@ describe("ParameterCoordinator", () => { } }); + 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); diff --git a/packages/live2d/src/runtime/controller/controller.ts b/packages/live2d/src/runtime/controller/controller.ts index 556126a..48cc622 100644 --- a/packages/live2d/src/runtime/controller/controller.ts +++ b/packages/live2d/src/runtime/controller/controller.ts @@ -58,8 +58,7 @@ export class Live2dRuntimeController { // 1. Semantic parameter detection this.semanticLayer.detectFromModel(model); - // Drop anything queued against the previous model, and the add - // contributions that describe its parameters. + // Drop writes queued against the previous model. this.coordinator.reset(); // 2. Motion layer system @@ -150,7 +149,10 @@ export class Live2dRuntimeController { // 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(); + const onBeforeModelUpdate = () => { + this.coordinator.flush(); + this.semanticLayer.captureRenderedValues(); + }; internalModel.on("beforeModelUpdate", onBeforeModelUpdate); this._tickerCallbacks.push(() => { internalModel.off("beforeModelUpdate", onBeforeModelUpdate); @@ -232,9 +234,8 @@ export class Live2dRuntimeController { /** * Get current semantic parameter values for DevTools display. * - * A held override wins over the engine's value: held values are applied for - * one frame at a time and the engine restores its own baseline afterwards, so - * the raw parameter does not report what the runtime is applying. + * 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(); @@ -242,9 +243,7 @@ export class Live2dRuntimeController { for (const name of profile.detected.keys()) { result.push({ name, - value: - this.coordinator.getHeldValue(name) ?? - this.semanticLayer.getSemantic(name), + value: this.semanticLayer.getRenderedSemantic(name), }); } return result; diff --git a/packages/live2d/src/runtime/controller/coordinator.ts b/packages/live2d/src/runtime/controller/coordinator.ts index 77e9740..efb63b6 100644 --- a/packages/live2d/src/runtime/controller/coordinator.ts +++ b/packages/live2d/src/runtime/controller/coordinator.ts @@ -10,8 +10,9 @@ interface QueuedWrite { priority: SystemPriority; } -interface HeldOverride { +interface HeldWrite { value: number; + blendMode: BlendMode; source: string; priority: SystemPriority; } @@ -36,13 +37,13 @@ interface HeldOverride { * * - `queueWrite()` - a one-frame contribution. Every `add`, and every override * a subsystem re-sends each frame (blink, motion layers), belongs here. - * - `holdOverride()` - an override that must survive frames the writer does not + * - `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 held = new Map>(); private conflictLog: ConflictEntry[] = []; private semanticLayer: SemanticParameterLayer; private maxLogSize: number; @@ -82,44 +83,76 @@ export class ParameterCoordinator { } /** - * Hold `parameter` at `value` until it is replaced or released. + * 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. * - * A lower-priority hold never replaces a higher-priority one (MANUAL=1 is the - * highest); that suppression is logged like any other conflict. + * Keep suppressed sources so they can take over when a higher-priority + * source releases its hold. Additive holds contribute on every frame. */ - holdOverride( + holdWrite( parameter: string, value: number, + blendMode: BlendMode, source: string, priority: SystemPriority, ): void { - const existing = this.held.get(parameter); - if (existing && existing.priority < priority) { - this.logConflict(parameter, existing, { value, source }); - return; + 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); + } + } } - this.held.set(parameter, { value, source, priority }); + 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 override. With `source` given only that source's hold is + * 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 { - const existing = this.held.get(parameter); - if (!existing) return; - if (source !== undefined && existing.source !== source) return; - this.held.delete(parameter); + 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); } - /** Value a held override currently pins `parameter` to, if any. */ - getHeldValue(parameter: string): number | undefined { - return this.held.get(parameter)?.value; + 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; } /** @@ -131,13 +164,13 @@ export class ParameterCoordinator { for (const [parameter, writes] of this.queue) { this.resolveParameter(parameter, writes); } - // A held override whose parameter nobody wrote this frame still has to be + // 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, held] of this.held) { + for (const parameter of this.held.keys()) { if (!this.queue.has(parameter)) { - this.applyAbsolute(parameter, held.value); + this.resolveParameter(parameter, []); } } } @@ -174,7 +207,8 @@ export class ParameterCoordinator { private resolveParameter(parameter: string, writes: QueuedWrite[]): void { // Single pass: pick the highest-priority override and sum every add. // (filter/filter/reduce/reduce allocated four arrays per parameter per frame.) - let winner: QueuedWrite | null = null; + let winner: QueuedWrite | HeldWrite | null = null; + let winnerIsHeld = false; let addSum = 0; let hasAdd = false; @@ -191,41 +225,33 @@ export class ParameterCoordinator { } const held = this.held.get(parameter); - - if (winner === null) { - if (held === undefined) { - // Only relative writes: stack them on the engine's current value. The - // engine drops them when it restores its baseline, so they never - // accumulate across frames. - const current = this.semanticLayer.getSemantic(parameter) ?? 0; - this.applyAbsolute(parameter, current + addSum); - return; + 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; + } } - - // A hold with relative writes on top of it: no queued override left to - // resolve a conflict against, and every queued write here is an add. - this.applyAbsolute(parameter, held.value + addSum); - return; } // Resolve override conflicts: lowest priority number wins (MANUAL=1 is highest) - for (const write of writes) { - if (write !== winner && write.blendMode === "override") { - this.logConflict(parameter, winner, write); + if (winner && !winnerIsHeld) { + for (const write of writes) { + if (write !== winner && write.blendMode === "override") { + this.logConflict(parameter, winner, write); + } } } - - // A held override outranks a queued one unless the queued one has a - // strictly higher priority; on a tie the held value - the established - // state - wins. Queued writes suppressed by a hold are not logged: the hold - // is re-applied every frame, so logging them would flood the conflict log. - const effective = - held !== undefined && held.priority <= winner.priority - ? held.value - : winner.value; - - // Adds don't conflict, they accumulate on top of the winning override. - this.applyAbsolute(parameter, hasAdd ? effective + addSum : effective); + const current = this.semanticLayer.getSemantic(parameter) ?? 0; + this.applyAbsolute(parameter, (winner?.value ?? current) + (hasAdd ? addSum : 0)); } private logConflict( 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..b1ed2d0 --- /dev/null +++ b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { SemanticParameterLayer } from "../../semantic"; +import { ProceduralAnimator } from "../animator"; +import { MutableParameterSet } from "../parameter-set"; + +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); + }); +}); diff --git a/packages/live2d/src/runtime/procedural/animator.ts b/packages/live2d/src/runtime/procedural/animator.ts index 1fb66b5..938024e 100644 --- a/packages/live2d/src/runtime/procedural/animator.ts +++ b/packages/live2d/src/runtime/procedural/animator.ts @@ -24,7 +24,7 @@ export class ProceduralAnimator implements ProceduralModule { } animate(options: AnimationOptions): Promise { - const currentValue = this.semanticLayer.getSemantic(options.target) ?? 0; + const currentValue = this.semanticLayer.getRenderedSemantic(options.target) ?? 0; const easing = typeof options.easing === "string" ? getEasing(options.easing) 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 add0052..cb71a87 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 @@ -184,17 +184,18 @@ describe("SemanticParameterLayer", () => { const core = createCubism4MockModel(["PARAM_ANGLE_X"]); layer.detectFromModel(wrapModel(core)); - const holdOverride = vi.fn(); + const holdWrite = vi.fn(); const releaseOverride = vi.fn(); layer.setCoordinator({ - holdOverride, + holdWrite, releaseOverride, } as unknown as ParameterCoordinator); layer.holdSemantic("angleX", 5, "manual", SystemPriority.MANUAL); - expect(holdOverride).toHaveBeenCalledWith( + expect(holdWrite).toHaveBeenCalledWith( "angleX", 5, + "override", "manual", SystemPriority.MANUAL, ); @@ -213,6 +214,17 @@ describe("SemanticParameterLayer", () => { }); }); + 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 b6f6912..2ea9e07 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. @@ -144,7 +166,7 @@ export class SemanticParameterLayer { } /** - * Hold a semantic parameter at a value until it is replaced or released. + * Hold an override or additive contribution until it is replaced or released. * Unlike `setSemantic`, the value is re-applied on every frame, so a writer * that only knows its value once keeps taking effect. */ @@ -153,18 +175,20 @@ export class SemanticParameterLayer { value: number, source: string, priority: SystemPriority, + blendMode: BlendMode = "override", ): void { const param = this.resolved.get(name); if (!param || !this.accessor) return; if (this.coordinator) { - this.coordinator.holdOverride(name, value, source, priority); + this.coordinator.holdWrite(name, value, blendMode, source, priority); return; } const min = this.accessor.getMin(param.index); const max = this.accessor.getMax(param.index); - this.accessor.setValue(param.index, Math.max(min, Math.min(max, value))); + const current = blendMode === "add" ? this.accessor.getValue(param.index) : 0; + this.accessor.setValue(param.index, Math.max(min, Math.min(max, current + value))); } /** Release a held semantic parameter so the engine takes it back. */ From 5b0a6a70109d85b919d83e93683f61f3c386b282 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Mon, 28 Sep 2026 17:11:28 +0800 Subject: [PATCH 6/8] fix: preserve terminal parameter values and exit resets --- .../behavior/__tests__/behavior-fsm.test.ts | 18 ++++ packages/live2d/src/runtime/behavior/fsm.ts | 17 +++- .../controller/__tests__/coordinator.test.ts | 99 ++++++++++++++++++- .../src/runtime/controller/coordinator.ts | 78 ++++++++++++--- .../__tests__/emotion-timeline.test.ts | 29 ++++++ .../live2d/src/runtime/emotion/timeline.ts | 24 +++++ .../procedural/__tests__/animator.test.ts | 24 +++++ .../live2d/src/runtime/procedural/animator.ts | 21 ++++ .../procedural/procedural-animation-system.ts | 1 + .../semantic-parameter-layer.test.ts | 7 +- .../semantic/semantic-parameter-layer.ts | 16 ++- 11 files changed, 305 insertions(+), 29 deletions(-) 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 3cc1b18..ec7bf4e 100644 --- a/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts +++ b/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts @@ -374,6 +374,24 @@ describe("BehaviorFSM", () => { 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", () => { diff --git a/packages/live2d/src/runtime/behavior/fsm.ts b/packages/live2d/src/runtime/behavior/fsm.ts index 9da32a4..f25129a 100644 --- a/packages/live2d/src/runtime/behavior/fsm.ts +++ b/packages/live2d/src/runtime/behavior/fsm.ts @@ -117,13 +117,15 @@ 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); } - this.releaseActiveSemanticParameters(); // 3. Call enter hook on new state if (toState.onEnter) { @@ -292,4 +294,15 @@ export class BehaviorFSM { } 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__/coordinator.test.ts b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts index 20821bc..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 { @@ -390,6 +393,60 @@ describe("ParameterCoordinator", () => { 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); @@ -433,9 +490,45 @@ describe("ParameterCoordinator", () => { expect(rig.angleX()).toBe(5); } - // The hold suppresses the write every frame, so it is not logged as a - // conflict - that would flood the log. - expect(rig.coordinator.getConflictLog()).toEqual([]); + // 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", () => { diff --git a/packages/live2d/src/runtime/controller/coordinator.ts b/packages/live2d/src/runtime/controller/coordinator.ts index efb63b6..3b87d5e 100644 --- a/packages/live2d/src/runtime/controller/coordinator.ts +++ b/packages/live2d/src/runtime/controller/coordinator.ts @@ -45,6 +45,8 @@ 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; @@ -65,6 +67,8 @@ export class ParameterCoordinator { reset(): void { this.queue.clear(); this.held.clear(); + this.activeHeldQueueConflicts.clear(); + this.nextHeldQueueConflicts.clear(); } /** @@ -161,8 +165,10 @@ export class ParameterCoordinator { * 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. @@ -170,11 +176,13 @@ export class ParameterCoordinator { if (this.held.size > 0) { for (const parameter of this.held.keys()) { if (!this.queue.has(parameter)) { - this.resolveParameter(parameter, []); + this.resolveParameter(parameter, [], heldQueueConflicts); } } } this.queue.clear(); + this.nextHeldQueueConflicts = this.activeHeldQueueConflicts; + this.activeHeldQueueConflicts = heldQueueConflicts; } /** @@ -202,12 +210,18 @@ export class ParameterCoordinator { */ clearConflictLog(): void { this.conflictLog = []; + this.activeHeldQueueConflicts.clear(); + this.nextHeldQueueConflicts.clear(); } - private resolveParameter(parameter: string, writes: QueuedWrite[]): void { + 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 winner: QueuedWrite | HeldWrite | null = null; + let queuedWinner: QueuedWrite | null = null; let winnerIsHeld = false; let addSum = 0; let hasAdd = false; @@ -215,8 +229,8 @@ export class ParameterCoordinator { for (const write of writes) { if (write.blendMode === "override") { // Lower priority number wins; on a tie keep the first one queued. - if (winner === null || write.priority < winner.priority) { - winner = write; + if (queuedWinner === null || write.priority < queuedWinner.priority) { + queuedWinner = write; } } else { addSum += write.value; @@ -224,6 +238,16 @@ export class ParameterCoordinator { } } + // 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); + } + } + } + + let winner: QueuedWrite | HeldWrite | null = queuedWinner; const held = this.held.get(parameter); if (held) { for (const write of held.values()) { @@ -242,11 +266,30 @@ export class ParameterCoordinator { } } - // Resolve override conflicts: lowest priority number wins (MANUAL=1 is highest) - if (winner && !winnerIsHeld) { - for (const write of writes) { - if (write !== winner && write.blendMode === "override") { - this.logConflict(parameter, winner, write); + // 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 { + for (const write of held.values()) { + if (write.blendMode === "override" && write.source !== winner.source) { + this.logHeldQueueConflict( + parameter, + winner, + write, + heldQueueConflicts, + ); + } } } } @@ -254,6 +297,19 @@ export class ParameterCoordinator { this.applyAbsolute(parameter, (winner?.value ?? current) + (hasAdd ? addSum : 0)); } + 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: { value: number; source: string }, 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 index b1ed2d0..07252af 100644 --- a/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts +++ b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts @@ -16,4 +16,28 @@ describe("ProceduralAnimator", () => { 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); + }); }); diff --git a/packages/live2d/src/runtime/procedural/animator.ts b/packages/live2d/src/runtime/procedural/animator.ts index 938024e..59929cc 100644 --- a/packages/live2d/src/runtime/procedural/animator.ts +++ b/packages/live2d/src/runtime/procedural/animator.ts @@ -18,6 +18,7 @@ export class ProceduralAnimator implements ProceduralModule { enabled = true; private animations: ActiveAnimation[] = []; private semanticLayer: SemanticParameterLayer; + private heldTargets = new Set(); constructor(semanticLayer: SemanticParameterLayer) { this.semanticLayer = semanticLayer; @@ -25,6 +26,9 @@ export class ProceduralAnimator implements ProceduralModule { animate(options: AnimationOptions): Promise { const currentValue = this.semanticLayer.getRenderedSemantic(options.target) ?? 0; + if (this.heldTargets.delete(options.target)) { + this.semanticLayer.releaseSemantic(options.target, "animator"); + } const easing = typeof options.easing === "string" ? getEasing(options.easing) @@ -64,7 +68,24 @@ export class ProceduralAnimator implements ProceduralModule { if (index >= 0) { this.animations.splice(index, 1); } + } + + // A completed animation should leave its target visible after the engine + // restores the frame baseline. A newer animation for the same parameter + // still owns the target, so only the last completed writer can hold it. + for (const anim of completed) { + if (!this.animations.some((active) => active.target === anim.target)) { + this.semanticLayer.holdSemantic(anim.target, anim.to, "animator", 5); + this.heldTargets.add(anim.target); + } anim.onComplete?.(); } } + + releaseHeldTargets(): void { + for (const target of this.heldTargets) { + this.semanticLayer.releaseSemantic(target, "animator"); + } + this.heldTargets.clear(); + } } diff --git a/packages/live2d/src/runtime/procedural/procedural-animation-system.ts b/packages/live2d/src/runtime/procedural/procedural-animation-system.ts index 93e41f8..78572d0 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.releaseHeldTargets(); 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 cb71a87..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 @@ -204,13 +204,14 @@ describe("SemanticParameterLayer", () => { expect(releaseOverride).toHaveBeenCalledWith("angleX", "manual"); }); - it("writes and clamps without a coordinator", () => { + it("requires a coordinator for persistent holds", () => { const layer = new SemanticParameterLayer(); const core = createCubism4MockModel(["PARAM_ANGLE_X"]); layer.detectFromModel(wrapModel(core)); - layer.holdSemantic("angleX", 100, "manual", SystemPriority.MANUAL); - expect(layer.getSemantic("angleX")).toBe(30); // clamped to max + expect(() => layer.holdSemantic("angleX", 100, "manual", SystemPriority.MANUAL)) + .toThrow("holdSemantic requires a coordinator"); + expect(layer.getSemantic("angleX")).toBe(0); }); }); diff --git a/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts b/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts index 2ea9e07..6e1c813 100644 --- a/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts +++ b/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts @@ -167,8 +167,7 @@ export class SemanticParameterLayer { /** * Hold an override or additive contribution until it is replaced or released. - * Unlike `setSemantic`, the value is re-applied on every frame, so a writer - * that only knows its value once keeps taking effect. + * Requires a coordinator, which re-applies the value on every engine frame. */ holdSemantic( name: SemanticName, @@ -180,15 +179,12 @@ export class SemanticParameterLayer { const param = this.resolved.get(name); if (!param || !this.accessor) return; - if (this.coordinator) { - this.coordinator.holdWrite(name, value, blendMode, source, priority); - return; + if (!this.coordinator) { + throw new Error( + "SemanticParameterLayer: holdSemantic requires a coordinator", + ); } - - const min = this.accessor.getMin(param.index); - const max = this.accessor.getMax(param.index); - const current = blendMode === "add" ? this.accessor.getValue(param.index) : 0; - this.accessor.setValue(param.index, Math.max(min, Math.min(max, current + value))); + this.coordinator.holdWrite(name, value, blendMode, source, priority); } /** Release a held semantic parameter so the engine takes it back. */ From eed0d5075a4ef737aed6f86bd58fc822816b4480 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Mon, 28 Sep 2026 17:30:57 +0800 Subject: [PATCH 7/8] fix: prevent stale animations from reclaiming parameters --- .../controller/__tests__/controller.test.ts | 61 ++++++++++++++++++- .../procedural/__tests__/animator.test.ts | 39 ++++++++++++ .../live2d/src/runtime/procedural/animator.ts | 25 +++++--- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts index a0fe70d..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,8 @@ +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", () => { @@ -176,6 +178,63 @@ describe("Live2dRuntimeController", () => { ]); }); + 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/procedural/__tests__/animator.test.ts b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts index 07252af..51a4a38 100644 --- a/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts +++ b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts @@ -40,4 +40,43 @@ describe("ProceduralAnimator", () => { 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); + }); }); diff --git a/packages/live2d/src/runtime/procedural/animator.ts b/packages/live2d/src/runtime/procedural/animator.ts index 59929cc..9baa41f 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 { @@ -29,6 +29,16 @@ export class ProceduralAnimator implements ProceduralModule { 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) @@ -42,7 +52,7 @@ export class ProceduralAnimator implements ProceduralModule { duration: options.duration, elapsed: 0, easing, - onComplete: resolve, + settle: resolve, }); }); } @@ -71,14 +81,11 @@ export class ProceduralAnimator implements ProceduralModule { } // A completed animation should leave its target visible after the engine - // restores the frame baseline. A newer animation for the same parameter - // still owns the target, so only the last completed writer can hold it. + // restores the frame baseline. for (const anim of completed) { - if (!this.animations.some((active) => active.target === anim.target)) { - this.semanticLayer.holdSemantic(anim.target, anim.to, "animator", 5); - this.heldTargets.add(anim.target); - } - anim.onComplete?.(); + this.semanticLayer.holdSemantic(anim.target, anim.to, "animator", 5); + this.heldTargets.add(anim.target); + anim.settle?.(); } } From bff1052d43128d2262af27494a523d06d98cc377 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Mon, 28 Sep 2026 17:58:31 +0800 Subject: [PATCH 8/8] fix: settle animations on model teardown --- .../procedural/__tests__/animator.test.ts | 22 +++++++++++++++++++ .../live2d/src/runtime/procedural/animator.ts | 8 +++++++ .../procedural/procedural-animation-system.ts | 2 +- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts index 51a4a38..a42512d 100644 --- a/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts +++ b/packages/live2d/src/runtime/procedural/__tests__/animator.test.ts @@ -2,6 +2,7 @@ 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", () => { @@ -79,4 +80,25 @@ describe("ProceduralAnimator", () => { 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 9baa41f..1e632a7 100644 --- a/packages/live2d/src/runtime/procedural/animator.ts +++ b/packages/live2d/src/runtime/procedural/animator.ts @@ -95,4 +95,12 @@ export class ProceduralAnimator implements ProceduralModule { } 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 78572d0..8102c27 100644 --- a/packages/live2d/src/runtime/procedural/procedural-animation-system.ts +++ b/packages/live2d/src/runtime/procedural/procedural-animation-system.ts @@ -87,7 +87,7 @@ export class ProceduralAnimationSystem { ticker.remove(this.tickerCallback); } this.tickerCallback = undefined; - this.animator.releaseHeldTargets(); + this.animator.stopAll(); this.modules = []; this.parameterSet.clear(); }