如何统一实现逐帧调整与固定时长触发的Lerp位置插值逻辑
统一双场景Lerp移动方案
你当前两套实现的核心差异本质是插值策略的不同,完全可以通过一层抽象封装成统一接口,不需要在业务层写两套逻辑。
现有实现的问题
- 第一种每帧动态目标的实现属于指数平滑跟随,直接用固定
0.9作为lerp系数没有做帧率适配,帧率波动时跟随速度会明显变化,高帧率下跟手偏慢、低帧率下容易冲过目标 - 第二种固定时长补间的实现属于定长过渡动画,没有内置目标变更时的打断、续跑逻辑,手动维护raf循环容易在组件卸载、目标快速变更时出现内存泄漏、动画错乱
统一封装思路
把两类场景的公共逻辑抽成独立的移动控制器,对外只暴露一个更新目标坐标的方法,内部自动处理插值策略选择、帧循环启停、帧率适配、清理逻辑:
- 内置两种插值模式:平滑跟随(对应小幅度高频目标变更)、固定时长补间(对应大幅度单次目标变更),支持手动指定模式,也支持根据目标位移幅度自动切换模式
- 统一帧循环逻辑,不管哪种模式都复用同一个raf tick流程,自动在到达目标时停止循环,避免空跑性能损耗
- 内置帧率修正,保证不同刷新率设备上的移动速度表现一致
- 内置清理方法,适配类SolidJS框架的组件生命周期(从你代码里的
createEffect/onCleanup判断你用的是Solid)
具体实现代码
// 基础插值工具函数 const lerp = (a, b, t) => a + (b - a) * t class UnifiedLerpMover { /** * @param {Object} positionRef 要移动的位置对象,即你代码里的_ * @param {Object} options 配置项 */ constructor(positionRef, options = {}) { this.pos = positionRef // 当前实际坐标 this.curX = positionRef.x this.curY = positionRef.y // 目标坐标 this.targetX = null this.targetY = null // 帧循环相关 this.rafId = null this.lastFrameTime = 0 this.tweenStart = 0 // 上一帧的目标值,用来判断目标是否在高频变动 this.lastRecordTargetX = null this.lastRecordTargetY = null // 合并默认配置 this.config = Object.assign({ smoothFactor: 0.9, // 平滑跟随的基准系数(以60帧为基准) tweenDuration: 1000, // 固定补间时长,单位ms tweenEasing: t => t * 0.8, // 补间缓动曲线 autoModeDistanceThreshold: 5, // 自动模式下,位移超过该值就用固定补间,否则用平滑跟随 mode: 'auto' // 可选值: auto / smooth / tween }, options) this.activeMode = null } /** * 对外唯一调用入口:更新目标坐标 * @param {number} x 目标x * @param {number} y 目标y */ setTarget(x, y) { this.targetX = x this.targetY = y // 没在跑循环就启动 if (!this.rafId) { this.lastFrameTime = performance.now() // 决定本次用什么模式 if (this.config.mode === 'tween' || (this.config.mode === 'auto' && this.#getTargetDistance() > this.config.autoModeDistanceThreshold)) { this.activeMode = 'tween' this.tweenStart = this.lastFrameTime } else { this.activeMode = 'smooth' } this.#tick() } } // 内部帧更新逻辑 #tick = () => { const now = performance.now() const dt = now - this.lastFrameTime this.lastFrameTime = now if (this.activeMode === 'smooth') { // 帧率修正:以60帧每帧16.67ms为基准,保证不同帧率下跟随速度一致 const adjustedFactor = 1 - Math.pow(1 - this.config.smoothFactor, dt / 16.67) this.curX = lerp(this.curX, this.targetX, adjustedFactor) this.curY = lerp(this.curY, this.targetY, adjustedFactor) // 已经足够接近目标、且目标停止变动时,终止循环 const isTargetStable = Math.abs(this.targetX - this.lastRecordTargetX) < 0.01 && Math.abs(this.targetY - this.lastRecordTargetY) < 0.01 if (this.#getTargetDistance() < 0.01 && isTargetStable) { this.#stop() return } this.lastRecordTargetX = this.targetX this.lastRecordTargetY = this.targetY } else { // 固定时长补间逻辑 const elapsed = now - this.tweenStart const progress = Math.min(elapsed / this.config.tweenDuration, 1) const easeVal = this.config.tweenEasing(progress) this.curX = lerp(this.curX, this.targetX, easeVal) this.curY = lerp(this.curY, this.targetY, easeVal) if (progress >= 1) { // 补间走完了,如果目标还没到(中途变更过)就重启补间,否则停止 if (this.#getTargetDistance() < 0.01) { this.#stop() return } this.tweenStart = now } } // 同步坐标到目标位置对象 this.pos.x = this.curX this.pos.y = this.curY this.rafId = requestAnimationFrame(this.#tick) } // 计算当前位置到目标的直线距离 #getTargetDistance() { const dx = this.targetX - this.curX const dy = this.targetY - this.curY return Math.hypot(dx, dy) } // 停止帧循环 #stop() { if (this.rafId) { cancelAnimationFrame(this.rafId) this.rafId = null } } // 组件卸载时调用,清理资源 destroy() { this.#stop() } }
业务层使用方式
不需要再写两套不同的effect逻辑,整个组件生命周期内只需要初始化一次控制器,所有场景都调用同一个setTarget方法即可:
// 初始化控制器,传入你的位置对象_ const mover = new UnifiedLerpMover(_, { // 这里可以传自定义配置覆盖默认值 }) // 组件卸载时自动清理 onCleanup(() => mover.destroy()) // 不管是每帧小幅更新目标,还是单次跳变更新目标,都只需要这一段逻辑 createEffect(() => { mover.setTarget(x, y) })
扩展说明
- 如果需要更丰富的缓动效果,只需要修改配置里的
tweenEasing传入对应缓动函数即可,不需要改动核心逻辑 - 如果需要手动强制使用某一种模式,只需要初始化时把
mode配置改成对应值即可,比如做拖拽跟手场景就强制设为smooth,做页面跳转的位移动画就强制设为tween - 这套逻辑也可以直接扩展到旋转、缩放等其他需要插值的属性,只需要把x/y的逻辑改成对应属性即可
内容的提问来源于stack exchange,提问作者eguneys
相关产品推荐
相关产品推荐

