本文提供常用的数值曲线公式在代码优化方面的小技巧,例子比较简单,抛砖引玉。

对于一些规则不稳定的需求,如某匹配机制的高级动态调整函数、某些积分计算,在调试迭代完成之前,往往需要频繁重构。此时以脚本实现一个公式,相比配置表是一个更为灵活便捷的方式。这里介绍一个常用函数,使用python编写。

《新手数值策划如何快速捏曲线公式》

文中提到的曲线拟合公式在项目中的代码实现如下,其中FP为帧同步使用的定点数类型。

public static FP CalEvalAttr_Old(FP x, FP b, FP c, FP r){
  c = -c;
  FP a = -FP.One / (FP.Atan(b * c) - FP.Atan(b * c + r * b));
  FP d = FP.Atan(b * c) / (FP.Atan(b * c) - FP.Atan(b * c + r * b));
  return a * FP.Atan(b * (x + c)) + d;
}

由于公式是由python用遗传算法生成的,里面有大量冗余计算,例如 FP.Atan(b*c) ,以及a和d的分母部分是完全一样。从计算机基础原理可知,反三角函数会带来较大的性能开销。项目中FP.Atan使用的是查表法(注,查表之前需要做变换,还要处理正负无穷),但是多次重复查表依然有巨大的优化空间。

尝试进行数学上和代码上等价变换移除冗余计算(善用宇宙第一IDE VisualStudio代码重构提取公共表达式)

public static FP CalEvalAttr(FP x, FP b, FP c, FP r)
{
  FP f = b * (-c);
  FP atanf = FP.Atan(f);
  return (atanf - FP.Atan(f + x * b)) / (atanf - FP.Atan(f + r * b));
}

反三角函数计算从6次减少为3次,其他四则运算也一定程度减少。考虑计算复杂度,预估应该会获得双倍的性能。

那么事实上是如此么?


且慢,不做任何单元测试是重构大忌,先撸一段单元测试测一下!参考数据来自项目真实代码。
PS:还不会在Unity里面做单元测试的小伙伴可以等我的视频课。

using FixMath;
using NUnit.Framework;

class TestCalEvalAttr
{
  [Test]
  public void Test1()
  {
    var a1 = (double)CalEvalAttr(3, 0.1f, 5, 30) * 15;
    var a2 = (double)CalEvalAttr_Old(3, 0.1f, 5, 30) * 15;

    Assert.AreEqual(a1, a2, 1e-8);

    var c1 = (double)CalEvalAttr(20f / 60, 0.2f, 10, 21) * 10;
    var c2 = (double)CalEvalAttr_Old(20f / 60, 0.2f, 10, 21) * 10;

    Assert.AreEqual(c1, c2, 1e-8);
  }
}

注意,浮点数是不能直接比较相等的,一般使用Assert.AreEqual第三个参数控制允许的误差精度,示例为小数点后第8位。

datacurve_optimize_unittest.jpg

测试通过,比较稳了!

现在来测一下性能有多少提升,同样也可以用单元测试来简单跑个分,测试负载为循环计算10000次。

[Test]
public void Test2()
{
  FP a1 = FP.Zero;
  FP a2 = FP.Zero;

  long t0 = RealTime.ticks;
  {
    for (int i = 0; i < _loopCount; i++)
      a1 = CalEvalAttr(3, 0.1f, 5, 30);
  }
  long t1 = RealTime.ticks;
  Debug.Log($"CalEvalAttr*{_loopCount} {t1 - t0}ms");

  t0 = RealTime.ticks;
  {
    for (int i = 0; i < _loopCount; i++)
      a2 = CalEvalAttr_Old(3, 0.1f, 5, 30);
  }
  t1 = RealTime.ticks;
  Debug.Log($"CalEvalAttr_Old*{_loopCount} {t1 - t0}ms");

  Assert.AreEqual((float)a1, (float)a2);  // 小数点后8位都ok,试试直接转成单精度浮点比较
}

private const int _loopCount = 10000;

流弊的结果出来了,23毫秒 vs 47毫秒,与预估的性能提升几乎一致,计算性能提升了100%。

Test2 (0.076s)
---
CalEvalAttr*10000 23ms
CalEvalAttr_Old*10000 47ms

可能有人会说10000次调用才快了20多毫秒?

实际项目中大量数值拟合计算,特别是用在做随机决策的评估函数,极端情况下一帧调用上千次也是正常现象。那么问题来了,如果要跑满60fps,一帧内的所有计算和渲染不能超过16ms,区区1000次计算从5ms优化到2ms就是巨大的提升。


最后,总结一下如何做这类密集计算型的小优化:

  • 公式等价变换,中小学数学要过关
  • 代码等价变换,善用重构工具
  • 重构后要用单元测试验证
  • 用实际的性能分析来印证计算复杂度估算