fix(animation): blend ambient motion into transitions

This commit is contained in:
smontlouis 2026-08-14 00:14:52 +02:00
parent fa103c38b5
commit 2061ce204c
7 changed files with 225 additions and 73 deletions

View File

@ -1,9 +1,13 @@
import { import {
ambientBodyOffset, ambientBodyOffset,
ambientEyeOffset,
applyAmbientBodyMotion,
applyAmbientMotion, applyAmbientMotion,
hasAmbientMotion, hasAmbientMotion,
} from '@/features/avatar/ambientMotion' } from '@/features/avatar/ambientMotion'
import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { surfacePresets } from '@/features/avatar/surfaces'
describe('perpetual expression motion', () => { describe('perpetual expression motion', () => {
it('leaves a motionless expression unchanged', () => { it('leaves a motionless expression unchanged', () => {
@ -27,6 +31,38 @@ describe('perpetual expression motion', () => {
expect(expression.positionXLeft).toBe(defaultExpression.positionXLeft) expect(expression.positionXLeft).toBe(defaultExpression.positionXLeft)
}) })
it('fades into the first micro-adjustment immediately, then holds the new target', () => {
const expression = { ...defaultExpression, eyeMotion: 'microSaccades' as const }
const halfway = ambientEyeOffset(expression, 70)
const target = ambientEyeOffset(expression, 200)
expect(ambientEyeOffset(expression, 0)).toEqual({ x: 0, y: 0 })
expect(halfway).not.toEqual({ x: 0, y: 0 })
expect(halfway).not.toEqual(target)
expect(target).toEqual(ambientEyeOffset(expression, 1000))
})
it('keeps eye micro-adjustments separate from the transitioning expression', () => {
const expression = { ...defaultExpression, eyeMotion: 'microSaccades' as const }
const transitioning = { ...expression, headX: 42, headY: -18, headZ: 71 }
expect(applyAmbientBodyMotion(expression, 500)).toEqual(expression)
expect(ambientEyeOffset(expression, 500)).not.toEqual({ x: 0, y: 0 })
expect(ambientEyeOffset(transitioning, 500)).toEqual(ambientEyeOffset(expression, 500))
})
it('applies the eye offset as a render layer without moving the head', () => {
const pose = poseFromExpression(defaultExpression)
const base = renderAvatar(pose, surfacePresets.sphere, 1)
const adjusted = renderAvatar(pose, surfacePresets.sphere, 1, {
eyeOffset: { x: 1.5, y: -0.75 },
})
expect(adjusted.headPath).toBe(base.headPath)
expect(adjusted.leftPath).not.toBe(base.leftPath)
expect(adjusted.rightPath).not.toBe(base.rightPath)
})
it('adds body motion without changing eye placement', () => { it('adds body motion without changing eye placement', () => {
const expression = { ...defaultExpression, bodyMotion: 'shake' as const } const expression = { ...defaultExpression, bodyMotion: 'shake' as const }
const animated = applyAmbientMotion(expression, 500) const animated = applyAmbientMotion(expression, 500)
@ -42,6 +78,27 @@ describe('perpetual expression motion', () => {
expect(ambientBodyOffset(expression, 500)).not.toEqual({ x: 0, y: 0 }) expect(ambientBodyOffset(expression, 500)).not.toEqual({ x: 0, y: 0 })
}) })
it('ramps perpetual motion with transition strength', () => {
const expression = {
...defaultExpression,
eyeMotion: 'shake' as const,
bodyMotion: 'shake' as const,
}
const fullBodyOffset = ambientBodyOffset(expression, 500)
const halfBodyOffset = ambientBodyOffset(expression, 500, 0.5)
const fullEyeOffset = ambientEyeOffset(expression, 500)
const halfEyeOffset = ambientEyeOffset(expression, 500, 0.5)
expect(ambientBodyOffset(expression, 500, 0).x).toBeCloseTo(0)
expect(ambientBodyOffset(expression, 500, 0).y).toBeCloseTo(0)
expect(ambientEyeOffset(expression, 500, 0).x).toBeCloseTo(0)
expect(ambientEyeOffset(expression, 500, 0).y).toBeCloseTo(0)
expect(halfBodyOffset.x).toBeCloseTo(fullBodyOffset.x / 2)
expect(halfBodyOffset.y).toBeCloseTo(fullBodyOffset.y / 2)
expect(halfEyeOffset.x).toBeCloseTo(fullEyeOffset.x / 2)
expect(halfEyeOffset.y).toBeCloseTo(fullEyeOffset.y / 2)
})
it('visibly offsets a symmetric body during slow drift', () => { it('visibly offsets a symmetric body during slow drift', () => {
const expression = { ...defaultExpression, bodyMotion: 'slowDrift' as const } const expression = { ...defaultExpression, bodyMotion: 'slowDrift' as const }

View File

@ -17,6 +17,7 @@ const hash = (value: number) => {
const expressionSeed = (expression: Expression) => const expressionSeed = (expression: Expression) =>
expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37 expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
const EYE_MOTION_SEED = 17.29
const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => { const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => {
const progress = elapsedMs / interval const progress = elapsedMs / interval
@ -29,68 +30,86 @@ const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: nu
const saccade = (elapsedMs: number, axis: number, seed: number) => { const saccade = (elapsedMs: number, axis: number, seed: number) => {
const interval = 1100 const interval = 1100
const warpedTime = elapsedMs + Math.sin(elapsedMs / 1700 + seed) * 280 const duration = 140
const step = Math.floor(warpedTime / interval) if (elapsedMs <= 0) return 0
const progress = (((warpedTime % interval) + interval) % interval) / interval const step = Math.floor(elapsedMs / interval)
const blend = smoothstep(Math.min(progress / 0.09, 1)) const progress = (elapsedMs - step * interval) / duration
const previous = hash((step - 1) * 2 + axis + seed) const blend = smoothstep(Math.min(progress, 1))
const previous = step === 0 ? 0 : hash((step - 1) * 2 + axis + seed)
const next = hash(step * 2 + axis + seed) const next = hash(step * 2 + axis + seed)
const fadeIn = Math.min(elapsedMs / 240, 1) return previous + (next - previous) * blend
return (previous + (next - previous) * blend) * fadeIn
} }
export const hasAmbientMotion = (expression: Expression) => export const hasAmbientMotion = (expression: Expression) =>
expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none' expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
export const ambientBodyOffset = (expression: Expression, elapsedMs: number) => { export const ambientBodyOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
const seed = expressionSeed(expression) const seed = expressionSeed(expression)
if (expression.bodyMotion === 'slowDrift') { if (expression.bodyMotion === 'slowDrift') {
return { return {
x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45, x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45 * strength,
y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1, y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1 * strength,
} }
} }
if (expression.bodyMotion === 'shake') { if (expression.bodyMotion === 'shake') {
const time = elapsedMs / 1000 const time = elapsedMs / 1000
return { return {
x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35, x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35 * strength,
y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1, y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1 * strength,
} }
} }
return { x: 0, y: 0 } return { x: 0, y: 0 }
} }
export const applyAmbientMotion = (expression: Expression, elapsedMs: number): Expression => { export const ambientEyeOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
if (expression.eyeMotion === 'microSaccades') {
return {
x: saccade(elapsedMs, 0, EYE_MOTION_SEED) * 1.5 * strength,
y: saccade(elapsedMs, 1, EYE_MOTION_SEED) * 0.9 * strength,
}
}
if (expression.eyeMotion === 'shake') {
const time = elapsedMs / 1000
return {
x: (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 * strength,
y: (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 * strength,
}
}
return { x: 0, y: 0 }
}
export const applyAmbientBodyMotion = (
expression: Expression,
elapsedMs: number,
strength = 1
): Expression => {
const next = { ...expression } const next = { ...expression }
const seed = expressionSeed(expression) const seed = expressionSeed(expression)
if (expression.eyeMotion === 'microSaccades') {
const x = saccade(elapsedMs, 0, seed) * 1.5
const y = saccade(elapsedMs, 1, seed) * 0.9
next.positionXLeft += x
next.positionXRight += x
next.positionYLeft += y
next.positionYRight += y
} else if (expression.eyeMotion === 'shake') {
const time = elapsedMs / 1000
const x = (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2
const y = (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8
next.positionXLeft += x
next.positionXRight += x
next.positionYLeft += y
next.positionYRight += y
}
if (expression.bodyMotion === 'slowDrift') { if (expression.bodyMotion === 'slowDrift') {
next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 * strength
next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 * strength
next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 * strength
} else if (expression.bodyMotion === 'shake') { } else if (expression.bodyMotion === 'shake') {
const time = elapsedMs / 1000 const time = elapsedMs / 1000
next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 * strength
next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 * strength
next.headZ += Math.sin(time * 43) * 0.7 next.headZ += Math.sin(time * 43) * 0.7 * strength
} }
return next return next
} }
export const applyAmbientMotion = (
expression: Expression,
elapsedMs: number,
strength = 1
): Expression => {
const next = applyAmbientBodyMotion(expression, elapsedMs, strength)
const eyeOffset = ambientEyeOffset(expression, elapsedMs, strength)
next.positionXLeft += eyeOffset.x
next.positionXRight += eyeOffset.x
next.positionYLeft += eyeOffset.y
next.positionYRight += eyeOffset.y
return next
}

View File

@ -61,6 +61,7 @@ export type AvatarGeometry = {
export type RenderAvatarOptions = { export type RenderAvatarOptions = {
includeWire?: boolean includeWire?: boolean
bodyNodes?: BodyNode[] bodyNodes?: BodyNode[]
eyeOffset?: Readonly<{ x: number; y: number }>
} }
export type EyeEditorGeometry = { export type EyeEditorGeometry = {
@ -552,15 +553,16 @@ const eyePoints = (
pose: AvatarPose, pose: AvatarPose,
surface: SurfaceConfig, surface: SurfaceConfig,
side: -1 | 1, side: -1 | 1,
blink: number blink: number,
offset: Readonly<{ x: number; y: number }> = { x: 0, y: 0 }
): ProjectedSurfacePoint[] => { ): ProjectedSurfacePoint[] => {
const expression = pose.expression const expression = pose.expression
const suffix = side < 0 ? 'Left' : 'Right' const suffix = side < 0 ? 'Left' : 'Right'
const width = expression[`width${suffix}`] const width = expression[`width${suffix}`]
const restingHeight = expression[`height${suffix}`] const restingHeight = expression[`height${suffix}`]
const height = 5 + (restingHeight - 5) * blink const height = 5 + (restingHeight - 5) * blink
const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x
const centerY = expression[`positionY${suffix}`] const centerY = expression[`positionY${suffix}`] + offset.y
const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle) const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
return roundedRectangle(width, height).map(([localX, localY]) => { return roundedRectangle(width, height).map(([localX, localY]) => {
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle) const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
@ -1230,8 +1232,8 @@ export const renderAvatar = (
blink = 1, blink = 1,
options: RenderAvatarOptions = {} options: RenderAvatarOptions = {}
): AvatarGeometry => { ): AvatarGeometry => {
const leftSamples = eyePoints(pose, surface, -1, blink) const leftSamples = eyePoints(pose, surface, -1, blink, options.eyeOffset)
const rightSamples = eyePoints(pose, surface, 1, blink) const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
const left = leftSamples.map(sample => sample.point) const left = leftSamples.map(sample => sample.point)
const right = rightSamples.map(sample => sample.point) const right = rightSamples.map(sample => sample.point)
const accessories = accessoryLayers(pose, options.bodyNodes ?? []) const accessories = accessoryLayers(pose, options.bodyNodes ?? [])

View File

@ -105,28 +105,38 @@ function mountAvatar(target, options = {}) {
let pausedBlink = null; let pausedBlink = null;
let pausedBlinkDelay = 0; let pausedBlinkDelay = 0;
let stepDueAt = null; let stepDueAt = null;
let ambientStartedAt = performance.now(); let eyeAmbientStartedAt = performance.now();
let ambientSignature = initialExpression.eyeMotion + ':' + initialExpression.bodyMotion; let bodyAmbientStartedAt = performance.now();
let eyeAmbientSignature = initialExpression.eyeMotion;
let bodyAmbientSignature = initialExpression.bodyMotion;
let ambientStrength = 1;
let lastAmbientFrame = 0; let lastAmbientFrame = 0;
const applyMotion = expression => { const applyMotion = expression => {
const signature = expression.eyeMotion + ':' + expression.bodyMotion; const now = performance.now();
if (signature !== ambientSignature) { if (expression.eyeMotion !== eyeAmbientSignature) {
ambientSignature = signature; eyeAmbientSignature = expression.eyeMotion;
ambientStartedAt = performance.now(); eyeAmbientStartedAt = now;
}
if (expression.bodyMotion !== bodyAmbientSignature) {
bodyAmbientSignature = expression.bodyMotion;
bodyAmbientStartedAt = now;
} }
}; };
const render = (time = performance.now()) => { const render = (time = performance.now()) => {
const elapsed = time - ambientStartedAt; const eyeElapsed = time - eyeAmbientStartedAt;
const expression = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression) const bodyElapsed = time - bodyAmbientStartedAt;
? AvatarProceduralEngine.applyAmbientMotion(currentPose.expression, elapsed) const expression = currentPose.expression.bodyMotion !== 'none'
? AvatarProceduralEngine.applyAmbientBodyMotion(currentPose.expression, bodyElapsed, ambientStrength)
: currentPose.expression; : currentPose.expression;
const eyeOffset = AvatarProceduralEngine.ambientEyeOffset(currentPose.expression, eyeElapsed, ambientStrength);
const renderedPose = AvatarProceduralEngine.poseFromExpression(expression); const renderedPose = AvatarProceduralEngine.poseFromExpression(expression);
const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, { const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, {
includeWire: false, includeWire: false,
bodyNodes: DATA.avatar.bodyNodes, bodyNodes: DATA.avatar.bodyNodes,
eyeOffset,
}); });
const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, elapsed); const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, bodyElapsed, ambientStrength);
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')'); motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
ensurePaths(backLayer, geometry.backPaths, currentColors.body); ensurePaths(backLayer, geometry.backPaths, currentColors.body);
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body); ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
@ -145,6 +155,7 @@ function mountAvatar(target, options = {}) {
if (transitionState) { if (transitionState) {
const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs); const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs);
const eased = easeProgress(linear, transitionState.transition); const eased = easeProgress(linear, transitionState.transition);
ambientStrength = clamp01(eased);
const expression = { ...transitionState.fromPose.expression }; const expression = { ...transitionState.fromPose.expression };
AvatarProceduralEngine.expressionFields.forEach(field => { AvatarProceduralEngine.expressionFields.forEach(field => {
expression[field] = transitionState.fromPose.expression[field] + expression[field] = transitionState.fromPose.expression[field] +
@ -161,6 +172,7 @@ function mountAvatar(target, options = {}) {
currentPose = transitionState.toPose; currentPose = transitionState.toPose;
currentColors = transitionState.toColors; currentColors = transitionState.toColors;
transitionState = null; transitionState = null;
ambientStrength = 1;
} }
} }
if (blinkState) { if (blinkState) {
@ -195,6 +207,7 @@ function mountAvatar(target, options = {}) {
const targetPose = AvatarProceduralEngine.poseFromExpression(resolved); const targetPose = AvatarProceduralEngine.poseFromExpression(resolved);
const targetColors = resolveColors(target); const targetColors = resolveColors(target);
if (durationMs <= 0) { if (durationMs <= 0) {
ambientStrength = 1;
transitionState = null; transitionState = null;
currentPose = targetPose; currentPose = targetPose;
currentColors = targetColors; currentColors = targetColors;
@ -212,6 +225,7 @@ function mountAvatar(target, options = {}) {
transition, transition,
expressionId, expressionId,
}; };
ambientStrength = 0;
requestTick(); requestTick();
}; };
const clearSchedule = () => { const clearSchedule = () => {

View File

@ -1,2 +1,8 @@
export { expressionFields, poseFromExpression, renderAvatar } from '../avatar/geometry' export { expressionFields, poseFromExpression, renderAvatar } from '../avatar/geometry'
export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../avatar/ambientMotion' export {
ambientBodyOffset,
ambientEyeOffset,
applyAmbientBodyMotion,
applyAmbientMotion,
hasAmbientMotion,
} from '../avatar/ambientMotion'

File diff suppressed because one or more lines are too long

View File

@ -41,7 +41,8 @@ import {
} from '@/features/animation/sequences' } from '@/features/animation/sequences'
import { import {
ambientBodyOffset, ambientBodyOffset,
applyAmbientMotion, ambientEyeOffset,
applyAmbientBodyMotion,
hasAmbientMotion, hasAmbientMotion,
} from '@/features/avatar/ambientMotion' } from '@/features/avatar/ambientMotion'
import { import {
@ -282,10 +283,14 @@ export function useStudioController() {
const displayedPose = useRef<AvatarPose>(initialPose) const displayedPose = useRef<AvatarPose>(initialPose)
const transitionFrame = useRef<number | null>(null) const transitionFrame = useRef<number | null>(null)
const ambientFrame = useRef<number | null>(null) const ambientFrame = useRef<number | null>(null)
const ambientStartedAt = useRef(0) const eyeAmbientStartedAt = useRef(-1)
const lastAmbientElapsed = useRef(0) const bodyAmbientStartedAt = useRef(-1)
const lastEyeAmbientElapsed = useRef(0)
const lastBodyAmbientElapsed = useRef(0)
const lastAmbientFrame = useRef(0) const lastAmbientFrame = useRef(0)
const ambientSignature = useRef('none:none') const eyeAmbientSignature = useRef('none')
const bodyAmbientSignature = useRef('none')
const lastAmbientStrength = useRef(1)
const transitionTarget = useRef<Expression>({ ...initialExpression }) const transitionTarget = useRef<Expression>({ ...initialExpression })
const canonicalTarget = useRef<Expression>({ ...initialExpression }) const canonicalTarget = useRef<Expression>({ ...initialExpression })
const retargetFrom = useRef<Expression | null>(null) const retargetFrom = useRef<Expression | null>(null)
@ -320,35 +325,68 @@ export function useStudioController() {
const bodyColorAnimation = useRef<ReturnType<typeof animate> | null>(null) const bodyColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
const eyeColorAnimation = useRef<ReturnType<typeof animate> | null>(null) const eyeColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
const paintPose = (pose: AvatarPose, blink?: number, frameTimeMs?: number) => { const paintPose = (
pose: AvatarPose,
blink?: number,
frameTimeMs?: number,
ambientStrength?: number
) => {
displayedPose.current = pose displayedPose.current = pose
const resolvedAmbientStrength =
ambientStrength ?? (transitionFrame.current === null ? 1 : lastAmbientStrength.current)
lastAmbientStrength.current = resolvedAmbientStrength
paintRenderedRotationGizmo(renderedRotationGizmo, pose.expression) paintRenderedRotationGizmo(renderedRotationGizmo, pose.expression)
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
const signature = `${pose.expression.eyeMotion}:${pose.expression.bodyMotion}` const eyeAmbientEnabled = !reduceMotion && pose.expression.eyeMotion !== 'none'
if (signature !== ambientSignature.current) { const bodyAmbientEnabled = !reduceMotion && pose.expression.bodyMotion !== 'none'
ambientSignature.current = signature const eyeSignature = eyeAmbientEnabled ? pose.expression.eyeMotion : 'none'
ambientStartedAt.current = -1 const bodySignature = bodyAmbientEnabled ? pose.expression.bodyMotion : 'none'
lastAmbientElapsed.current = 0 if (eyeSignature !== eyeAmbientSignature.current) {
eyeAmbientSignature.current = eyeSignature
eyeAmbientStartedAt.current = -1
lastEyeAmbientElapsed.current = 0
} }
if (frameTimeMs !== undefined) { if (bodySignature !== bodyAmbientSignature.current) {
if (ambientStartedAt.current < 0) ambientStartedAt.current = frameTimeMs bodyAmbientSignature.current = bodySignature
lastAmbientElapsed.current = frameTimeMs - ambientStartedAt.current bodyAmbientStartedAt.current = -1
lastBodyAmbientElapsed.current = 0
} }
const renderedExpression = if (eyeAmbientEnabled && frameTimeMs !== undefined) {
!reduceMotion && hasAmbientMotion(pose.expression) if (eyeAmbientStartedAt.current < 0) eyeAmbientStartedAt.current = frameTimeMs
? applyAmbientMotion(pose.expression, lastAmbientElapsed.current) lastEyeAmbientElapsed.current = frameTimeMs - eyeAmbientStartedAt.current
: pose.expression }
if (bodyAmbientEnabled && frameTimeMs !== undefined) {
if (bodyAmbientStartedAt.current < 0) bodyAmbientStartedAt.current = frameTimeMs
lastBodyAmbientElapsed.current = frameTimeMs - bodyAmbientStartedAt.current
}
const renderedExpression = bodyAmbientEnabled
? applyAmbientBodyMotion(
pose.expression,
lastBodyAmbientElapsed.current,
resolvedAmbientStrength
)
: pose.expression
const eyeOffset = eyeAmbientEnabled
? ambientEyeOffset(pose.expression, lastEyeAmbientElapsed.current, resolvedAmbientStrength)
: { x: 0, y: 0 }
const renderPose = avatar const renderPose = avatar
? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes) ? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes)
: poseFromExpression(renderedExpression) : poseFromExpression(renderedExpression)
const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), { const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), {
includeWire: showWireRef.current || highlightRef.current === 'head', includeWire: showWireRef.current || highlightRef.current === 'head',
bodyNodes: bodyNodesRef.current, bodyNodes: bodyNodesRef.current,
eyeOffset,
}) })
paintRenderedScene(renderedScene, geometry) paintRenderedScene(renderedScene, geometry)
paintRenderedOffset( paintRenderedOffset(
renderedScene, renderedScene,
ambientBodyOffset(pose.expression, lastAmbientElapsed.current) bodyAmbientEnabled
? ambientBodyOffset(
pose.expression,
lastBodyAmbientElapsed.current,
resolvedAmbientStrength
)
: { x: 0, y: 0 }
) )
} }
@ -474,6 +512,7 @@ export function useStudioController() {
stopTransition(true) stopTransition(true)
stopColorTransitions() stopColorTransitions()
const durationMs = transitionSettings.transitionMs const durationMs = transitionSettings.transitionMs
lastAmbientStrength.current = 0
const from = { ...current } const from = { ...current }
const fromColors = { const fromColors = {
body: renderedColors.body.get(), body: renderedColors.body.get(),
@ -511,7 +550,7 @@ export function useStudioController() {
body: interpolateHexColor(fromColors.body, targetColors.body, bounded(eased, 0, 1)), body: interpolateHexColor(fromColors.body, targetColors.body, bounded(eased, 0, 1)),
eyes: interpolateHexColor(fromColors.eyes, targetColors.eyes, bounded(eased, 0, 1)), eyes: interpolateHexColor(fromColors.eyes, targetColors.eyes, bounded(eased, 0, 1)),
}) })
paintPose(poseFromExpression(animated), undefined, time) paintPose(poseFromExpression(animated), undefined, time, bounded(eased, 0, 1))
if ( if (
modeRef.current === 'manual' && modeRef.current === 'manual' &&
time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS
@ -554,6 +593,12 @@ export function useStudioController() {
return return
} }
transitionTarget.current = resolvedTarget transitionTarget.current = resolvedTarget
lastAmbientStrength.current = 0
const initialTransitionDistance = expressionFields.reduce(
(total, field) => total + Math.abs(resolvedTarget[field] - current[field]),
0
)
let transitionProgress = 0
const tick = (time: number) => { const tick = (time: number) => {
const previousTime = lastTransitionTime.current ?? time const previousTime = lastTransitionTime.current ?? time
const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30) const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30)
@ -585,6 +630,15 @@ export function useStudioController() {
} }
} }
const target = transitionTarget.current const target = transitionTarget.current
const remainingTransitionDistance = expressionFields.reduce(
(total, field) => total + Math.abs(target[field] - currentExpression[field]),
0
)
const geometricProgress =
initialTransitionDistance <= 0
? 1
: 1 - remainingTransitionDistance / initialTransitionDistance
transitionProgress = Math.max(transitionProgress, bounded(geometricProgress, 0, 1))
let settled = true let settled = true
const animated = { ...currentExpression } const animated = { ...currentExpression }
animated.eyeMotion = target.eyeMotion animated.eyeMotion = target.eyeMotion
@ -609,7 +663,7 @@ export function useStudioController() {
return return
} }
paintPose(poseFromExpression(animated), undefined, time) paintPose(poseFromExpression(animated), undefined, time, transitionProgress)
if (modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS) { if (modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS) {
lastInspectorFrame.current = time lastInspectorFrame.current = time
setExpression(animated) setExpression(animated)