feat(stage-tamagotchi): capture idle tuning

Use the current joystick, generator, filter, and breath values as reset defaults. Store the excited trajectory beside the devtool for repeatable model tuning.
This commit is contained in:
Rin
2026-08-26 19:22:04 +08:00
parent 084b1904e5
commit b969e09907
10 changed files with 21790 additions and 28 deletions
File diff suppressed because it is too large Load Diff
@@ -23,9 +23,12 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const stateCount = shallowRef(4)
const order = shallowRef(6)
const residualStrength = shallowRef(0.8)
const defaultStateCount = 5
const defaultOrder = 12
const defaultResidualStrength = 0.8
const stateCount = shallowRef(defaultStateCount)
const order = shallowRef(defaultOrder)
const residualStrength = shallowRef(defaultResidualStrength)
const seed = shallowRef(1)
const model = shallowRef<Live2DMotionArHmmModel>()
const fitDurationMs = shallowRef(0)
@@ -238,7 +241,7 @@ onUnmounted(stopGeneration)
:min="2"
:max="8"
:step="1"
:default-value="4"
:default-value="defaultStateCount"
:format-value="formatCount"
as="div"
/>
@@ -249,7 +252,7 @@ onUnmounted(stopGeneration)
:min="1"
:max="18"
:step="1"
:default-value="6"
:default-value="defaultOrder"
:format-value="formatOrder"
as="div"
/>
@@ -260,7 +263,7 @@ onUnmounted(stopGeneration)
:min="0"
:max="3"
:step="0.025"
:default-value="0.8"
:default-value="defaultResidualStrength"
:format-value="formatStrength"
as="div"
/>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Live2DBreathControlOptions } from '@proj-airi/stage-ui-live2d/stores'
import { sampleLive2DBreath } from '@proj-airi/stage-ui-live2d/stores'
import { defaultLive2DBreathControlOptions, sampleLive2DBreath } from '@proj-airi/stage-ui-live2d/stores'
import { BasicButton, FieldRange } from '@proj-airi/ui'
import { useRafFn } from '@vueuse/core'
import { computed, shallowRef } from 'vue'
@@ -112,7 +112,7 @@ function formatRatio(value: number): string {
:min="0.5"
:max="15"
:step="0.1"
:default-value="3.2"
:default-value="defaultLive2DBreathControlOptions.cycleSeconds"
:format-value="formatSeconds"
as="div"
/>
@@ -123,7 +123,7 @@ function formatRatio(value: number): string {
:min="0.1"
:max="0.9"
:step="0.01"
:default-value="0.4"
:default-value="defaultLive2DBreathControlOptions.inhaleRatio"
:format-value="formatRatio"
as="div"
/>
@@ -134,7 +134,7 @@ function formatRatio(value: number): string {
:min="0"
:max="8"
:step="0.1"
:default-value="1.2"
:default-value="defaultLive2DBreathControlOptions.exhaleDwellSeconds"
:format-value="formatSeconds"
as="div"
/>
@@ -145,7 +145,7 @@ function formatRatio(value: number): string {
:min="0"
:max="1"
:step="0.01"
:default-value="0"
:default-value="defaultLive2DBreathControlOptions.minimum"
:format-value="formatBreathValue"
as="div"
/>
@@ -156,7 +156,7 @@ function formatRatio(value: number): string {
:min="0"
:max="1"
:step="0.01"
:default-value="0.5"
:default-value="defaultLive2DBreathControlOptions.maximum"
:format-value="formatBreathValue"
as="div"
/>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Live2DMotionControlDynamics, Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { defaultLive2DMotionControlDynamics, neutralLive2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores'
import { BasicButton, FieldRange } from '@proj-airi/ui'
import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -420,7 +420,7 @@ watch(() => props.disabled, (disabled) => {
:min="0"
:max="2"
:step="0.01"
:default-value="0.6"
:default-value="defaultLive2DMotionControlDynamics.follow"
:format-value="formatPercent"
as="div"
/>
@@ -431,7 +431,7 @@ watch(() => props.disabled, (disabled) => {
:min="0"
:max="1"
:step="0.01"
:default-value="0.35"
:default-value="defaultLive2DMotionControlDynamics.inertia"
:format-value="formatPercent"
as="div"
/>
@@ -8,6 +8,8 @@ import { BasicButton, FieldRange } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { defaultLive2DMotionOutputFilterOptions } from '../../composables/live2d-motion-output-filter-prototype'
const props = defineProps<{
options: Live2DMotionOutputFilterOptions
frame?: Live2DMotionOutputFilterFrame
@@ -95,7 +97,7 @@ function formatChange(value: number | undefined): string {
:min="0"
:max="0.99"
:step="0.01"
:default-value="0.8"
:default-value="defaultLive2DMotionOutputFilterOptions.smoothing"
:format-value="formatSmoothing"
as="div"
/>
@@ -106,7 +108,7 @@ function formatChange(value: number | undefined): string {
:min="0"
:max="0.2"
:step="0.0025"
:default-value="0.015"
:default-value="defaultLive2DMotionOutputFilterOptions.cutoff"
:format-value="formatCutoff"
as="div"
/>
@@ -23,8 +23,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const order = shallowRef(12)
const residualStrength = shallowRef(0.85)
const defaultOrder = 20
const defaultResidualStrength = 1.15
const order = shallowRef(defaultOrder)
const residualStrength = shallowRef(defaultResidualStrength)
const seed = shallowRef(1)
const model = shallowRef<Live2DMotionVarModel>()
const fitDurationMs = shallowRef(0)
@@ -218,7 +220,7 @@ onUnmounted(stopGeneration)
:min="1"
:max="48"
:step="1"
:default-value="12"
:default-value="defaultOrder"
:format-value="formatOrder"
as="div"
/>
@@ -229,7 +231,7 @@ onUnmounted(stopGeneration)
:min="0"
:max="3"
:step="0.025"
:default-value="0.85"
:default-value="defaultResidualStrength"
:format-value="formatStrength"
as="div"
/>
@@ -11,6 +11,14 @@ function pose(overrides: Partial<typeof neutralLive2DMotionControlPose> = {}) {
}
describe('live2DMotionOutputFilterPrototype', () => {
it('uses the tuned generator cutoff by default', () => {
expect(defaultLive2DMotionOutputFilterOptions).toEqual({
enabled: true,
smoothing: 0.8,
cutoff: 0.0575,
})
})
it('holds cumulative changes below the cutoff', () => {
const filter = createLive2DMotionOutputFilter({ enabled: true, smoothing: 0, cutoff: 0.05 })
@@ -22,7 +22,7 @@ export interface Live2DMotionOutputFilterOptions {
enabled: boolean
/** Weight of the previous output from 0 (raw) to 0.999 (slow). @default 0.8 */
smoothing: number
/** Smallest normalized change that updates a channel target. @default 0.015 */
/** Smallest normalized change that updates a channel target. @default 0.0575 */
cutoff: number
}
@@ -53,7 +53,7 @@ export interface Live2DMotionOutputFilterController {
export const defaultLive2DMotionOutputFilterOptions: Readonly<Live2DMotionOutputFilterOptions> = Object.freeze({
enabled: true,
smoothing: 0.8,
cutoff: 0.015,
cutoff: 0.0575,
})
function clamp(value: number, minimum: number, maximum: number): number {
@@ -2,11 +2,19 @@ import { describe, expect, it } from 'vitest'
import {
defaultLive2DBreathControlOptions,
defaultLive2DMotionControlDynamics,
getLive2DMotionControlModelOffset,
neutralLive2DMotionControlPose,
sampleLive2DBreath,
} from './motion-control'
describe('manual motion defaults', () => {
it('uses the tuned joystick spring and breath duration', () => {
expect(defaultLive2DMotionControlDynamics).toEqual({ follow: 1, inertia: 0.6 })
expect(defaultLive2DBreathControlOptions.cycleSeconds).toBe(2)
})
})
describe('getLive2DMotionControlModelOffset', () => {
it('moves the model with the active joystick pose', () => {
// ROOT CAUSE:
@@ -28,9 +28,9 @@ export interface Live2DMotionControlPose {
/** Spring settings for manual Live2D motion. */
export interface Live2DMotionControlDynamics {
/** Target-following strength from 0 (soft) to 2 (very fast). @default 0.6 */
/** Target-following strength from 0 (soft) to 2 (very fast). @default 1 */
follow: number
/** Preserved momentum from 0 (settled) to 1 (bouncy). @default 0.35 */
/** Preserved momentum from 0 (settled) to 1 (bouncy). @default 0.6 */
inertia: number
}
@@ -44,7 +44,7 @@ export interface Live2DMotionControlState {
/** Settings for the manual render-time breath curve. */
export interface Live2DBreathControlOptions {
/** Length of the inhale and exhale spans, in seconds. @default 3.2 */
/** Length of the inhale and exhale spans, in seconds. @default 2 */
cycleSeconds: number
/** Time held at the minimum after each exhale, in seconds. @default 1.2 */
exhaleDwellSeconds: number
@@ -110,7 +110,7 @@ export const neutralLive2DMotionControlPose: Live2DMotionControlPose = Object.fr
})
/** Default settings for the manual Live2D breath curve. */
export const defaultLive2DBreathControlOptions: Live2DBreathControlOptions = Object.freeze({
cycleSeconds: 3.2,
cycleSeconds: 2,
exhaleDwellSeconds: 1.2,
minimum: 0,
maximum: 0.5,
@@ -118,7 +118,7 @@ export const defaultLive2DBreathControlOptions: Live2DBreathControlOptions = Obj
})
const horizontalModelOffset = 20
/** Default spring settings for the Live2D motion devtool. */
export const defaultLive2DMotionControlDynamics: Live2DMotionControlDynamics = Object.freeze({ follow: 0.6, inertia: 0.35 })
export const defaultLive2DMotionControlDynamics: Live2DMotionControlDynamics = Object.freeze({ follow: 1, inertia: 0.6 })
function clampAxis(value: number): number {
return Math.min(1, Math.max(-1, value))