游戏人物移动效果对应实际刷新率对比与Client-side Prediction & Interpolation调整优化
前言
在制作游戏中任何图片的移动都会因为FPS设置的问题导致游戏体验度相对弱了很多,但是很多时候又不能放开了做,毕竟图片大了,刷新率在高一些很多手机或PC就无法正常的跑起来,这就很难受,我今天做了个对比测试,用一个60FPS的与低FPS的做了个对比,后面我对10FPS的单独用AI做了个优化,效果还是有一些的,希望能给大家创造一些价值。
项目开始
我这里纯纯的使用手机端做测试效果。

核心 FPS 处理函数与算法逻辑
| 阶段 | 核心逻辑实现 | 算法说明 | 视觉效果 |
|---|---|---|---|
| 1. 物理位置计算 | this.posX_smooth += this.speed * deltaTime; |
基于真实时间增量(DeltaTime)计算物体的理论物理位置,作为全局参考坐标系。 | 绝对丝滑 (60Hz+) |
| 2. 低频采样模拟 | if (now - lastUpdate >= jitterInterval) { targetPos = posX_smooth; } |
模拟低性能环境下的非连续采样。只有在达到设定的时间间隔(如 100ms)时才同步物理坐标。 | 视觉断裂、跳变感 |
| 3. 线性插值 (LERP) | lastPos + (targetPos - lastPos) * lerpFactor; |
核心优化算法。在两次采样点之间,根据当前帧距离上次采样的时间进度,手动计算中间补帧。 | 模拟高帧率的连贯性 |
| 4. 运动预测补偿 | posX_optimized += this.speed * deltaTime; |
当插值进度完成但新采样尚未到达时,根据物体历史速度进行惯性预测,消除微小的停顿感。 | 消除采样延迟抖动 |
无算法自己跑的各数值FPS与60FPS对比

@Entry
@Component
struct Index {
@State posX_smooth: number = 0;
@State posX_jitter: number = 0;
@State selectedFps: number = 15;
@State isRunning: boolean = true;
private screenWidth: number = 360;
private lastTime: number = 0;
private jitterLastUpdate: number = 0;
private speed: number = 80; // 降低移动速度,从200调至80,更方便观察刷新率细节
onPageShow() {
this.runLoop();
}
runLoop() {
let interval = 16; // ~60fps 的物理更新频率
setInterval(() => {
if (!this.isRunning) return;
const now = Date.now();
if (this.lastTime === 0) {
this.lastTime = now;
this.jitterLastUpdate = now;
}
const deltaTime = (now - this.lastTime) / 1000;
this.lastTime = now;
// 1. 标准移动逻辑(平滑参考):每 16ms 计算一次位移
this.posX_smooth += this.speed * deltaTime;
if (this.posX_smooth > 300) {
this.posX_smooth = -50; // 循环滚动
}
// 2. 刷新率问题演示:仅在满足用户选择的 FPS 时间间隔时,才更新视觉坐标
const jitterInterval = 1000 / this.selectedFps;
if (now - this.jitterLastUpdate >= jitterInterval) {
// 将“物理位置”同步到“视觉位置”,造成跳变感
this.posX_jitter = this.posX_smooth;
this.jitterLastUpdate = now;
}
}, interval);
}
build() {
Stack() {
// 1. 梯度渐变背景 (深邃生命科学风格)
Column()
.width('100%')
.height('100%')
.linearGradient({
angle: 135,
colors: [['#0F2027', 0], ['#203A43', 0.5], ['#2C5364', 1]]
})
// 2. 内容层
Column({ space: 30 }) {
Text("生命体运动刷新率对比")
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 60 })
.textShadow({ radius: 10, color: '#44FFFFFF', offsetX: 2, offsetY: 2 })
Text("模拟微观粒子在不同采样率下的视觉差异")
.fontSize(14)
.fontColor('#A0A0A0')
// 演示区域
Column({ space: 50 }) {
// 顺滑组
VStack({ title: "标准刷新 (60Hz)", posX: this.posX_smooth, opacityValue: 1, color: '#4CAF50' })
// 模拟组
VStack({
title: `模拟刷新 (${this.selectedFps}Hz)`,
posX: this.posX_jitter,
opacityValue: 0.8,
color: this.selectedFps < 30 ? '#F44336' : '#FFEB3B'
})
}
.width('90%')
.padding(20)
.backgroundColor('#1AFFFFFF')
.borderRadius(20)
.border({ width: 1, color: '#33FFFFFF' })
// 玻璃拟态效果
.backgroundBlurStyle(BlurStyle.Thin)
// 控制区
Column({ space: 20 }) {
Text("调节模拟刷新率")
.fontColor('#FFFFFF')
.fontSize(16)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center, alignContent: FlexAlign.Center }) {
ForEach([5, 10, 15, 24, 30, 60], (fps: number) => {
Button(`${fps} FPS`)
.onClick(() => {
this.selectedFps = fps;
})
.backgroundColor(this.selectedFps === fps ? '#007DFF' : '#33FFFFFF')
.fontColor('#FFFFFF')
.borderRadius(10)
.width('40%')
.margin(5)
})
}
.width('100%')
}
.width('90%')
.padding(20)
.backgroundColor('#0D000000')
.borderRadius(20)
}
.width('100%')
.height('100%')
}
}
}
@Component
struct VStack {
@Prop title: string = "";
@Prop posX: number = 0;
@Prop opacityValue: number = 1;
@Prop color: string = "#FFFFFF";
build() {
Column() {
Row() {
Circle({ width: 8, height: 8 }).fill(this.color).margin({ right: 8 })
Text(this.title)
.fontColor('#E0E0E0')
.fontSize(14)
}
.width('100%')
.margin({ bottom: 15 })
// 运动轨道
Stack({ alignContent: Alignment.Start }) {
// 轨道线
Rect()
.width('100%')
.height(2)
.fill('#22FFFFFF')
// 粒子 (demo.png)
Image($r('app.media.demo'))
.width(60)
.height(60)
.offset({ x: this.posX, y: 0 })
.shadow({ radius: 20, color: this.color })
.interpolation(ImageInterpolation.High)
}
.width('100%')
.height(80)
.opacity(this.opacityValue)
}
}
}
优化算法效果
算法核心函数抽离
/**
* 刷新率优化核心循环 (每 16ms 执行一次)
* @param now 当前系统时间
* @param deltaTime 距离上一帧的时间间隔
*/
function updateFrameOptimized(now: number, deltaTime: number) {
// A. 计算物理参考位置 (标准速度同步)
this.posX_smooth += this.speed * deltaTime;
// B. 模拟低频采样 (例如 10FPS)
const jitterInterval = 1000 / this.selectedFps;
if (now - this.jitterLastUpdate >= jitterInterval) {
this.lastJitterPos = this.posX_jitter; // 记录起点
this.targetJitterPos = this.posX_smooth; // 设定终点
this.posX_jitter = this.posX_smooth; // 更新采样点
this.jitterLastUpdate = now;
this.lerpFactor = 0; // 重置插值进度
}
// C. 插值算法优化:在 10FPS 的采样缝隙中“凭空”创造流畅度
if (this.lerpFactor < 1) {
// 计算插值进度:在采样周期内完成 0.0 到 1.0 的平滑过渡
this.lerpFactor += deltaTime * this.selectedFps;
if (this.lerpFactor > 1) this.lerpFactor = 1;
// 执行线性插值 (Linear Interpolation)
this.posX_optimized = this.lastJitterPos + (this.targetJitterPos - this.lastJitterPos) * this.lerpFactor;
} else {
// 预测移动:在等待下一次采样时,按照速度惯性继续微移,防止“原地踏步”
this.posX_optimized += this.speed * deltaTime;
}
}

@Entry
@Component
struct Index {
@State posX_smooth: number = 0;
@State posX_jitter: number = 0;
@State posX_optimized: number = 0; // 算法优化后的位置
@State selectedFps: number = 10;
@State isRunning: boolean = true;
private screenWidth: number = 360;
private lastTime: number = 0;
private jitterLastUpdate: number = 0;
private speed: number = 80;
// 算法插值相关变量
private lastJitterPos: number = 0;
private targetJitterPos: number = 0;
private lerpFactor: number = 0;
onPageShow() {
this.runLoop();
}
runLoop() {
let interval = 16;
setInterval(() => {
if (!this.isRunning) return;
const now = Date.now();
if (this.lastTime === 0) {
this.lastTime = now;
this.jitterLastUpdate = now;
}
const deltaTime = (now - this.lastTime) / 1000;
this.lastTime = now;
// 1. 标准移动 (参考系)
this.posX_smooth += this.speed * deltaTime;
if (this.posX_smooth > 300) {
this.posX_smooth = -50;
}
// 2. 10FPS 采样逻辑
const jitterInterval = 1000 / this.selectedFps;
if (now - this.jitterLastUpdate >= jitterInterval) {
this.lastJitterPos = this.posX_jitter;
this.targetJitterPos = this.posX_smooth;
this.posX_jitter = this.posX_smooth;
this.jitterLastUpdate = now;
this.lerpFactor = 0; // 重置插值进度
}
// 3. 算法优化:线性插值 (LERP) + 预测
// 虽然采样只有 10FPS,但我们在每帧 (16ms) 进行平滑补偿
if (this.lerpFactor < 1) {
this.lerpFactor += deltaTime * (this.selectedFps); // 在采样周期内完成移动
if (this.lerpFactor > 1) this.lerpFactor = 1;
// 核心算法:当前视觉位置 = 上一次采样点 + (目标采样点 - 上一次采样点) * 进度
this.posX_optimized = this.lastJitterPos + (this.targetJitterPos - this.lastJitterPos) * this.lerpFactor;
} else {
// 预测移动:在等待下一次采样时,按照速度惯性继续微移
this.posX_optimized += this.speed * deltaTime;
}
}, interval);
}
build() {
Stack() {
// 1. 梯度渐变背景
Column()
.width('100%')
.height('100%')
.linearGradient({
angle: 135,
colors: [['#0F2027', 0], ['#203A43', 0.5], ['#2C5364', 1]]
})
// 2. 内容层
Column({ space: 20 }) {
Text("AI 插值算法优化演示")
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 40 })
Text("在 10FPS 采样下实现 60FPS 的丝滑度")
.fontSize(14)
.fontColor('#00E5FF')
// 演示区域
Column({ space: 30 }) {
// 顺滑组 (60Hz)
VStack({ title: "原生 60Hz 采样 (丝滑)", posX: this.posX_smooth, opacityValue: 1, color: '#4CAF50' })
// 优化组 (10Hz + 算法)
VStack({
title: `10Hz 采样 + 线性插值算法 (模拟丝滑)`,
posX: this.posX_optimized,
opacityValue: 1,
color: '#007DFF'
})
// 原始低帧率组 (10Hz)
VStack({
title: `原生 10Hz 采样 (肉眼可见卡顿)`,
posX: this.posX_jitter,
opacityValue: 0.6,
color: '#F44336'
})
}
.width('95%')
.padding(15)
.backgroundColor('#1AFFFFFF')
.borderRadius(20)
.backgroundBlurStyle(BlurStyle.Thin)
// 控制区
Column({ space: 15 }) {
Text("调节基础采样率")
.fontColor('#FFFFFF')
.fontSize(16)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach([5, 10, 24, 60], (fps: number) => {
Button(`${fps} FPS`)
.onClick(() => {
this.selectedFps = fps;
})
.backgroundColor(this.selectedFps === fps ? '#007DFF' : '#33FFFFFF')
.fontColor('#FFFFFF')
.borderRadius(10)
.width('20%')
.margin(5)
})
}
.width('100%')
}
.width('95%')
.padding(15)
.backgroundColor('#0D000000')
.borderRadius(20)
}
.width('100%')
.height('100%')
}
}
}
@Component
struct VStack {
@Prop title: string = "";
@Prop posX: number = 0;
@Prop opacityValue: number = 1;
@Prop color: string = "#FFFFFF";
build() {
Column() {
Row() {
Circle({ width: 8, height: 8 }).fill(this.color).margin({ right: 8 })
Text(this.title)
.fontColor('#E0E0E0')
.fontSize(14)
}
.width('100%')
.margin({ bottom: 15 })
// 运动轨道
Stack({ alignContent: Alignment.Start }) {
// 轨道线
Rect()
.width('100%')
.height(2)
.fill('#22FFFFFF')
// 粒子 (demo.png)
Image($r('app.media.demo'))
.width(60)
.height(60)
.offset({ x: this.posX, y: 0 })
.shadow({ radius: 20, color: this.color })
.interpolation(ImageInterpolation.High)
}
.width('100%')
.height(80)
.opacity(this.opacityValue)
}
}
}
效果总结
- 原生 10FPS:每秒刷新 10 次,物体移动呈【瞬移】状态,容易引起视觉疲劳。
- 10FPS + LERP 优化:虽然有效数据只有 10 次,但屏幕每秒依然渲染 60 次,中间的 50 次渲染由算法根据轨迹预测生成。视觉上几乎等同于 60FPS 的流畅度。

升级优化效果5~120FPS全优化
这套 Demo 实现了一套模拟现代游戏引擎中【客户端预测与平滑】(Client-side Prediction & Interpolation)的核心算法。
核心解释:无论屏幕刷新率是多少,物体的真实物理位置(posX_smooth)始终基于真实时间增量(DeltaTime)进行线性累加。
人为地“掐断”物理层的实时反馈。只有当到达设定的采样时间点(如 10FPS,即每 100ms)时,才会从物理层“偷”一个坐标给它。
公式:当前位置 = 上一帧位置 + (移动速度 * 帧间隔时间)。
预算层的处理:
动态速度估算:算法不依赖于代码中写死的 speed,而是通过计算最近两次采样点(currentSamplePos - lastSamplePos)的位移差,除以采样时间间隔,计算出物体的体感速度(estimatedVelocity)。
外推预测 (Extrapolation):在等待下一次采样的过程中,算法会启动一个本地计时器(renderTime)。它假设物体会按照刚才计算出的体感速度继续运动。
计算公式:预测位置 = 最后一次采样坐标 + (体感速度 * 距离上次采样的时间)。

@Entry
@Component
struct Index {
@State posX_smooth: number = 0;
@State posX_jitter: number = 0;
@State posX_optimized: number = 0;
@State selectedFps: number = 10;
@State isRunning: boolean = true;
// 核心增强变量
private lastTime: number = 0;
private jitterLastUpdate: number = 0;
private speed: number = 80;
// 运动状态捕捉
private lastSamplePos: number = 0;
private currentSamplePos: number = 0;
private estimatedVelocity: number = 0; // 动态速度估算
private renderTime: number = 0; // 自定义渲染时钟
onPageShow() {
this.runLoop();
}
runLoop() {
let interval = 8; // 将物理更新频率提升至 ~120fps (1000ms / 120 ≈ 8ms)
setInterval(() => {
if (!this.isRunning) return;
const now = Date.now();
if (this.lastTime === 0) {
this.lastTime = now;
this.jitterLastUpdate = now;
}
const deltaTime = (now - this.lastTime) / 1000;
this.lastTime = now;
this.renderTime += deltaTime;
// 1. 物理引擎参考 (物理层)
this.posX_smooth += this.speed * deltaTime;
if (this.posX_smooth > 320) this.posX_smooth = -40;
// 2. 动态采样逻辑 (传输层)
const jitterInterval = 1000 / this.selectedFps;
if (now - this.jitterLastUpdate >= jitterInterval) {
const intervalSec = (now - this.jitterLastUpdate) / 1000;
this.lastSamplePos = this.currentSamplePos;
this.currentSamplePos = this.posX_smooth;
// 核心升级:动态计算采样间的瞬时速度,用于更精准的预测
if (intervalSec > 0) {
this.estimatedVelocity = (this.currentSamplePos - this.lastSamplePos) / intervalSec;
}
this.posX_jitter = this.currentSamplePos;
this.jitterLastUpdate = now;
this.renderTime = 0; // 重置渲染本地时钟,同步采样点
}
// 3. 全局通用优化算法 (表现层 - 状态预测渲染)
// 使用 Hermite 插值或简单的二次预测,这里采用更稳定的速度增量预测法
// 这种算法不依赖于特定的 FPS,它会自动适应任何采样频率
let predictionOffset = this.estimatedVelocity * this.renderTime;
// 限制预测范围,防止在极低 FPS 或剧烈加减速时产生飞出去的幻觉 (Error Correction)
let rawOptimized = this.currentSamplePos + predictionOffset;
// 软同步:让优化后的位置向物理位置进行平滑靠拢
this.posX_optimized = rawOptimized * 0.8 + this.posX_smooth * 0.2;
}, interval);
}
build() {
Stack() {
// 1. 梯度渐变背景
Column()
.width('100%')
.height('100%')
.linearGradient({
angle: 135,
colors: [['#0F2027', 0], ['#203A43', 0.5], ['#2C5364', 1]]
})
// 2. 内容层
Column({ space: 20 }) {
Text("AI 插值算法优化演示")
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 40 })
Text("通用状态预测与误差纠正算法 (支持至 120Hz)")
.fontSize(14)
.fontColor('#00E5FF')
// 演示区域
Column({ space: 30 }) {
// 顺滑组 (120Hz)
VStack({ title: "原生 120Hz 采样 (极度丝滑)", posX: this.posX_smooth, opacityValue: 1, color: '#4CAF50' })
// 优化组 (通用预测算法)
VStack({
title: `全帧率通用预测算法 (当前采样: ${this.selectedFps}Hz)`,
posX: this.posX_optimized,
opacityValue: 1,
color: '#007DFF'
})
// 原始低帧率组
VStack({
title: `原生 ${this.selectedFps}Hz 采样 (未优化)`,
posX: this.posX_jitter,
opacityValue: 0.6,
color: '#F44336'
})
}
.width('95%')
.padding(15)
.backgroundColor('#1AFFFFFF')
.borderRadius(20)
.backgroundBlurStyle(BlurStyle.Thin)
// 控制区
Column({ space: 15 }) {
Text("调节基础采样率")
.fontColor('#FFFFFF')
.fontSize(16)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach([5, 15, 30, 60, 90, 120], (fps: number) => {
Button(`${fps} FPS`)
.onClick(() => {
this.selectedFps = fps;
})
.backgroundColor(this.selectedFps === fps ? '#007DFF' : '#33FFFFFF')
.fontColor('#FFFFFF')
.borderRadius(10)
.width('25%')
.margin(5)
})
}
.width('100%')
}
.width('95%')
.padding(15)
.backgroundColor('#0D000000')
.borderRadius(20)
}
.width('100%')
.height('100%')
}
}
}
@Component
struct VStack {
@Prop title: string = "";
@Prop posX: number = 0;
@Prop opacityValue: number = 1;
@Prop color: string = "#FFFFFF";
build() {
Column() {
Row() {
Circle({ width: 8, height: 8 }).fill(this.color).margin({ right: 8 })
Text(this.title)
.fontColor('#E0E0E0')
.fontSize(14)
}
.width('100%')
.margin({ bottom: 15 })
// 运动轨道
Stack({ alignContent: Alignment.Start }) {
// 轨道线
Rect()
.width('100%')
.height(2)
.fill('#22FFFFFF')
// 粒子 (demo.png)
Image($r('app.media.demo'))
.width(60)
.height(60)
.offset({ x: this.posX, y: 0 })
.shadow({ radius: 20, color: this.color })
.interpolation(ImageInterpolation.High)
}
.width('100%')
.height(80)
.opacity(this.opacityValue)
}
}
}
所有代码均已贡献,希望能对大家有些帮助。
- 点赞
- 收藏
- 关注作者
评论(0)