fix(animation): blend ambient motion into transitions
This commit is contained in:
parent
fa103c38b5
commit
2061ce204c
@ -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 }
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 ?? [])
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user