fade timescale for smooth item anims

This commit is contained in:
wass08
2026-03-06 13:08:24 +01:00
parent 88d0231a95
commit 202fc41f42
2 changed files with 118 additions and 34 deletions
@@ -12,13 +12,12 @@ import {
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { Suspense, useEffect, useMemo, useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import type { Group, Material, Mesh, PointLight } from 'three'
import { Suspense, useEffect, useMemo, useRef } from 'react'
import type { AnimationAction, Group, Material, Mesh, PointLight } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { useShallow } from 'zustand/react/shallow'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { NodeRenderer } from '../node-renderer'
@@ -99,32 +98,6 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
// Freeze the interactive definition at mount — asset schemas don't change at runtime
const interactiveRef = useRef(node.asset.interactive)
// Subscribe only to this node's control values — useShallow prevents re-renders from other nodes' changes
const controlValues = useInteractive(useShallow((state) => state.items[node.id]?.controlValues))
useEffect(() => {
const interactive = interactiveRef.current
const animEffect = interactive?.effects.find(
(e): e is AnimationEffect => e.kind === 'animation',
)
if (!animEffect) {
// Non-interactive: play first available animation as default
if (animations.length > 0) actions[animations[0]!.name]?.play()
return
}
if (!controlValues) return
const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
const isOn = toggleIndex >= 0 ? Boolean(controlValues[toggleIndex]) : false
for (const action of Object.values(actions)) action?.stop()
const clipName = isOn ? animEffect.clips.on : (animEffect.clips.off ?? animEffect.clips.loop)
if (clipName) actions[clipName]?.play()
}, [controlValues, actions, animations])
if (nodes.cutout) {
nodes.cutout.visible = false
}
@@ -169,7 +142,10 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
}, [scene])
const interactive = interactiveRef.current
const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
const animEffect =
interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation') ?? null
const lightEffects =
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
return (
<>
@@ -181,6 +157,15 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
rotation={node.asset.rotation}
{...handlers}
/>
{animations.length > 0 && (
<ItemAnimation
nodeId={node.id}
animEffect={animEffect}
interactive={interactive ?? null}
actions={actions}
animations={animations}
/>
)}
{lightEffects.map((effect, i) => (
<ItemLight key={i} nodeId={node.id} effect={effect} interactive={interactive!} />
))}
@@ -188,6 +173,78 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
)
}
const ItemAnimation = ({
nodeId,
animEffect,
interactive,
actions,
animations,
}: {
nodeId: AnyNodeId
animEffect: AnimationEffect | null
interactive: Interactive | null
actions: Record<string, AnimationAction | null>
animations: { name: string }[]
}) => {
const activeClipRef = useRef<string | null>(null)
const fadingOutRef = useRef<AnimationAction | null>(null)
// Reactive: derive target clip name — only re-renders when the clip name itself changes
const targetClip = useInteractive((s) => {
const values = s.items[nodeId]?.controlValues
if (!animEffect) return animations[0]?.name ?? null
const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : false
return isOn
? (animEffect.clips.on ?? null)
: (animEffect.clips.off ?? animEffect.clips.loop ?? null)
})
// When target clip changes: kick off the transition
useEffect(() => {
// Cancel any ongoing fade-out immediately
if (fadingOutRef.current) {
fadingOutRef.current.timeScale = 0
fadingOutRef.current = null
}
// Move current clip to fade-out
if (activeClipRef.current && activeClipRef.current !== targetClip) {
const old = actions[activeClipRef.current]
if (old?.isRunning()) fadingOutRef.current = old
}
// Start new clip at timeScale 0.01 (as 0 would cause isRunning to be false and thus not play at all), then fade in to 1
activeClipRef.current = targetClip
if (targetClip) {
const next = actions[targetClip]
if (next) {
next.timeScale = 0.01
next.play()
}
}
}, [targetClip, actions])
// useFrame: only lerping — no logic
useFrame((_, delta) => {
if (fadingOutRef.current) {
const action = fadingOutRef.current
action.timeScale = MathUtils.lerp(action.timeScale, 0, Math.min(delta * 5, 1))
if (action.timeScale < 0.01) {
action.timeScale = 0
fadingOutRef.current = null
}
}
if (activeClipRef.current) {
const action = actions[activeClipRef.current]
if (action?.isRunning() && action.timeScale < 1) {
action.timeScale = MathUtils.lerp(action.timeScale, 1, Math.min(delta * 5, 1))
if (1 - action.timeScale < 0.01) action.timeScale = 1
}
}
})
return null
}
const ItemLight = ({
nodeId,
effect,
@@ -201,7 +258,8 @@ const ItemLight = ({
// Precompute stable indices — interactive is frozen at mount
const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')
const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider')
const sliderControl = sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null
const sliderControl =
sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null
useFrame((_, delta) => {
if (!lightRef.current) return
@@ -220,7 +278,11 @@ const ItemLight = ({
? MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], t)
: effect.intensityRange[0]
lightRef.current.intensity = MathUtils.lerp(lightRef.current.intensity, target, Math.min(delta * 12, 1))
lightRef.current.intensity = MathUtils.lerp(
lightRef.current.intensity,
target,
Math.min(delta * 12, 1),
)
})
return (