Skip to content

Last updated:

Animations in interactive applications

When animating properties and values in applications and games, we generally interpolate values between two values(or keyframes etc.). This

This works well for predefined animations, but animating between states in interactive apps, it get's complicated to manage multiple animations and their states.

To get around this, we can animate between the current and the target value, instead of needing a start value. This solves the need of tracking multiple from states and durations.

Standard lerp

typescript
function lerp(start: number, end: number, t: number): number {
    return start + (end - start) * t;
}

Now we can modify it to avoid the start value and use the current value instead. This way, we can animate towards a target value without needing to track the start value.

typescript
function lerpFromCurrent(current: number, target: number, t: number, dt): number {
    const l = 1 - t // time left
    const k = Math.max(0, Math.min(dt, l) / l)
    return current + (end - current) * k
}

Here we assume t is the current time in the animation, dt is the delta time. They are both normalized in range 0-1 based on the animation duration. That means, if animation duration is 5 secs, current time is 2 secs, and delta time is 0.1 secs, then t = 2/5 and dt = 0.1/5.

notes
typescript
// const td = t - dt
// const start = (val - (1 - td) * end) / td
// v = end * td + start * (1 - td)
// const start = td < 0 ? val : (val - td * end) / (1 - td)

// const i = delta / (end - time)
// const m = end - start
// end - time = m - (time - start)
// const duration = end - start
// i        = delta * (end - start) / (end - start) * (end - time)
//          = dt * 1 / (1 - t * 1)
//          = dt * m / (m - (time - start))
//          = dt * m / (end - time)
//          = dt * (end - start) / (end - time)
//          = dt * (time - start) / t(end - time)

Proper implementation

The implementation and type used in threepipe

typescript

export interface AnimateTime {t: number, dt: number, rm?: IRenderManager} // rm is used for textures, see below

export function lerpNumber(time: AnimateTime&{from?: number}, val: number, end: number): number {
    const {t, dt} = time
    let newVal
    if (t <= 0) newVal = time.from !== undefined ? time.from : val
    else if (t >= 1.) newVal = end
    else if (time.from !== undefined) {
        newVal = time.from * (1 - t) + end * t
    } else {
        const l = 1 - t
        const k = Math.max(0, Math.min(dt, l) / l)
        newVal = val + (end - val) * k
    }
    return newVal
}

The above function is to be used for numbers, and can be extended for types like Color, Vector2, Vector3, etc. by interpolating their components.

Checkout the threepipe source for other types and arrays - https://github.com/repalash/threepipe/blob/f33b51f69ae35a42ced0a0d1a600fd24eafa6b5e/src/utils/lerp.ts

Blending textures

This technique can also be used to morph textures, either by blending between from and to textures, or from the current texture(on a material) to the to texture.

There are multiple ways to blend texture, but generally requires multiple render/copy passes. But when interpolating linearly, and can be reduced to form a * t + b * (1-t), it's possible to use the default blending in webgl/opengl to just blend once.

The sample code below uses the blit method from the RenderManager in threepipe, that copies a texture over a target render target.

typescript
export function lerpTexture(time: AnimateTime&{from?: ITexture|null}, val: ITexture|null, end: ITexture|null): ITexture |null {
    if (!time.rm) {
        console.warn('MaterialManager: RenderManager is required for interpolating textures')
        return end
    }
    const {t, dt} = time
    let newVal
    const fromVal = time.from !== undefined ? time.from : val
    if (t <= 0 || t > 0.99 || fromVal === end) {
        newVal = t <= 0 ? fromVal : end
        let rt
        if (val && (val.userData as any)?._lerpTexture && val.isRenderTargetTexture) {
            rt = val._target
        }
        if (rt) {
            time.rm.releaseTempTarget(rt)
        }
    } else {

        const val1 = fromVal || whiteTexture
        let needsInit = false
        
        let rt
        if (val && (val.userData as any)?._lerpTexture && val.isRenderTargetTexture) {
            rt = val._target // get the render target from the texture. (in threepipe its set automatically, otherwise it needs to be added manually in vanilla three.js)
        } else {
            const anyTex = fromVal || end || whiteTexture
            const size = {
                width: anyTex.image?.width || anyTex.image?.naturalWidth || anyTex.image?.videoWidth || 1,
                height: anyTex.image?.height || anyTex.image?.naturalHeight || anyTex.image?.videoHeight || 1,
            }
            rt = time.rm.getTempTarget({
                size: size,
                colorSpace: anyTex.colorSpace,
                type: anyTex.type,
                format: anyTex.format,
                generateMipmaps: anyTex.generateMipmaps,
                minFilter: anyTex.minFilter,
                magFilter: anyTex.magFilter,
                wrapS: anyTex.wrapS,
                wrapT: anyTex.wrapT,
            });
            (rt.texture as Texture).userData._lerpTexture = true
            needsInit = true
        }
        if (rt) {
            if (time.from !== undefined || needsInit) {
                time.rm.blit(rt, {
                    source: val1,
                    respectColorSpace: false,
                    transparent: true,
                    clear: true,
                })
            }

            const l = 1 - t
            const k = Math.max(0, Math.min(dt, l) / l)
            // newVal = val + (end - val) * k
            // newVal = val * (1 - k) + end * k
            
            time.rm.blit(rt, {
                source: end || whiteTexture,
                respectColorSpace: false,
                transparent: true,
                clear: false,
                blendAlpha: time.from !== undefined ? t : k,
                // blending: NormalBlending,
            })
            newVal = rt.texture as ITexture
        } else {
            newVal = val
        }
    }
    return newVal
}

Animating between materials

Now that we have primitives to animate between numbers, colors, vectors, and textures, we can animate between materials as well.

To do this, collect the properties that need to be animated, and use the above functions to animate them.

In threepipe, the setValues function in Material is overloaded to accept an AnimateTime object, if passed, the values are set based on the state.

Here is a sample using popmotion plugin(that syncs with other plugins properly), but it can be done using any animation loop.

typescript
let last = 0
await viewer.getPlugin(PopmotionPlugin).animate({
    from: 0,
    to: 1,
    duration: 2000,
    onUpdate: (v) => {
        const dv = v - last
        last = v
        cube.material.setValues({ // this can be an object or another material.
            color: new Color(0xffffff),
            map: image,
            normalMap: normalImage,
            metalness: 1,
            roughness: 0,
            normalScale: new Vector2(1, 1),
        }, true, undefined, {
            t: v, dt: dv, // 0 to 1
            rm: viewer.renderManager,
            // optionally pass the `from` params/material.
            // from: {
            //     color: ...
            //     ...
            // }
        })
    },
}).promise

Checkout the threepipe material properties animation example for sample implementation and demo.

Made with ❤️ using the awesome vitepress