游戏人物移动效果对应实际刷新率对比与Client-side Prediction & Interpolation调整优化

举报
红目香薰 发表于 2026/03/03 21:46:47 2026/03/03
【摘要】 前言在制作游戏中任何图片的移动都会因为FPS设置的问题导致游戏体验度相对弱了很多,但是很多时候又不能放开了做,毕竟图片大了,刷新率在高一些很多手机或PC就无法正常的跑起来,这就很难受,我今天做了个对比测试,用一个60FPS的与低FPS的做了个对比,后面我对10FPS的单独用AI做了个优化,效果还是有一些的,希望能给大家创造一些价值。 项目开始我这里纯纯的使用手机端做测试效果。 核心 FPS...

前言

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

项目开始

我这里纯纯的使用手机端做测试效果。
1.png

核心 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对比

1.gif

@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;
    }
}

2.gif

@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 的流畅度。

3.gif

升级优化效果5~120FPS全优化

这套 Demo 实现了一套模拟现代游戏引擎中【客户端预测与平滑】(Client-side Prediction & Interpolation)的核心算法。


核心解释:无论屏幕刷新率是多少,物体的真实物理位置(posX_smooth)始终基于真实时间增量(DeltaTime)进行线性累加。
人为地“掐断”物理层的实时反馈。只有当到达设定的采样时间点(如 10FPS,即每 100ms)时,才会从物理层“偷”一个坐标给它。


公式:当前位置 = 上一帧位置 + (移动速度 * 帧间隔时间)。


预算层的处理:

动态速度估算:算法不依赖于代码中写死的 speed,而是通过计算最近两次采样点(currentSamplePos - lastSamplePos)的位移差,除以采样时间间隔,计算出物体的体感速度(estimatedVelocity)。

外推预测 (Extrapolation):在等待下一次采样的过程中,算法会启动一个本地计时器(renderTime)。它假设物体会按照刚才计算出的体感速度继续运动。

计算公式:预测位置 = 最后一次采样坐标 + (体感速度 * 距离上次采样的时间)。

4.png

@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)
    }
  }
}

所有代码均已贡献,希望能对大家有些帮助。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。