[TOC]
前言:
写这篇文章和库其实也是方便自己温习和改进,毕竟阅读过不少技术文章后,总会想着自己实现一套。
在客户端–服务器的实时游戏中,玩家输入、物理模拟和渲染必须跨多台设备严格同步,否则会出现“瞬移”、“抖动”或“不同步”的尴尬场景。传统浮点数运算由于舍入差异和平台差异往往难以保证位级一致;而定点数不仅精度固定、可预测,还能在多平台上实现完全相同的计算结果。
本文将实现一个基于 .NET Standard 2.1 定点数学库(以下简称“定点库”),并分析设计思路、关键实现。
1. 浮点数的不确定性
1.1 IEEE-754 双精度简述
- 总位宽:64 位
- 符号(1 位) + 指数(11 位,偏移 1023) + 尾数(52 位,隐含最高位 1)
- 任意实数都要被映射到 1.52×10⁻¹⁶ 的相对精度范围内。
0.1 的二进制表示
十进制 0.1₁₀ 转换为二进制是无限循环:
0.1₁₀ = 0.00011001100110011…₂
而硬件只能存 52 位尾数(加上隐含 1):
≈ 0.0001100110011001100110011001100110011001100110011010₂
└───────────────┬───────────────┘
精确截断到 52 位尾数
对应十进制:
0.1000000000000000055511151231257827021181583404541015625
与真实的 0.1 相差约 5.6×10⁻¹⁸。
1.2 累加示例:误差如何放大
下面用 C# 演示 0.1 的累加误差随迭代增长的实际输出:
double sum = 0.0;
for (int i = 1; i <= 12; i++) {
sum += 0.1;
Console.WriteLine($"i={i}\t sum={sum:R}\t error={sum - 0.1 * i:R}");
}
| i | sum (R) | error (sum − i×0.1) |
|---|---|---|
| 1 | 0.1 | +0.0 |
| 2 | 0.2 | +0.0 |
| 3 | 0.30000000000000004 | +4.4408920985006262E−17 |
| 4 | 0.4 | +0.0 |
| 5 | 0.5 | +0.0 |
| 6 | 0.6 | -1.1102230246251565E-16 |
| 7 | 0.7 | -1.1102230246251565E-16 |
| 8 | 0.7999999999999999 | -1.1102230246251565E-16 |
| 9 | 0.8999999999999999 | -1.1102230246251565E-16 |
| 10 | 0.9999999999999999 | -1.1102230246251565E-16 |
| 11 | 1.0999999999999999 | -2.220446049250313E-16 |
| 12 | 1.2 | -2.220446049250313E-16 |
- 误差并非单调增长,而是“振荡”后整体漂移;
- 每次尾数对齐与舍入都可能引入 ± 几个 ULP(单位最后有效位);
- 长时间迭代,会累积到可见规模,影响物理模拟一致性。
2. 定点数的优势
2.1 位级确定性
定点数「全整型」运算,无需尾数对齐,也无指数变化。只要各端 raw 值相同,加/减/移位/乘除都产生完全相同的结果。
2.2 可控精度与范围
选用 Q32.32(64 位)平衡精度与范围:
| 格式 | 范围 | 最小增量 |
|---|---|---|
| Q32.32 | ±2³¹ ≈ ±2.1×10⁹ | 2⁻³² ≈ 2.3×10⁻¹⁰ |
- 足够覆盖游戏世界坐标
- 精度高于像素级与物理判定需求
2.3 性能与优化
- 整型加减:与原生
long相同,极高效率。 - 乘除:可选用
- 大整数(
BigInteger)保证正确; - 手动拆分 64→32 高低位;
- SIMD/硬件指令(Burst、PMULLD)。
- 大整数(
- 无垃圾分配:避免 GC 影响帧率。
3. 定点库总体架构与模块划分
在打通了定点算术与误差背景之后,我们来看整个库的高层组织——如何划分模块、解耦依赖,以及设计背后的考量。
3.1 模块划分
XFixedPoint/
├─ Core/ ← 定点算术、比较、数学函数
├─ Matrices/ ← 4×4 定点矩阵变换
├─ Networking/ ← 状态快照、输入缓冲与回滚系统
├─ Physics/ ← 刚体、碰撞、物理系统
├─ Quaternions/ ← 定点四元数与旋转运算
├─ Utilities/ ← Time/Debug/Random工具集
└─ Vectors/ ← 定点向量(2D/3D/4D)
- 单一职责
- 每层只关心自己领域:
Core不依赖任何其它模块;Vectors只用Core;Quaternions、Matrices各司其职,Physics聚合前几层;Networking构建在物理之上。
- 每层只关心自己领域:
- 可替换性
- 未来如果要换用更高效的三角函数(CORDIC → 查表),只需替换
Core/XFixedMath;不必触及Physics、Networking。
- 未来如果要换用更高效的三角函数(CORDIC → 查表),只需替换
3.2 Core ↔ Vectors 解耦
- 依赖方向:
Vectors依赖Core.XFixed、Core.XFixedMath。Core不引用Vectors,只提供基础算术。
- 好处:
- 最小可测单元:
Core的单元测试独立、运行快速; - 低耦合:即使删除或替换
Vectors,也不破坏Core。
- 最小可测单元:
3.3 聚合层:PhysicsSystem
namespace XFixedPoint.Physics
{
public class PhysicsSystem
{
List<FixedRigidbody> _bodies;
List<FixedCollider> _colliders;
public void Step(XFixed dt) { … }
}
}
- 职责:
- 应用全局重力、积分各
FixedRigidbody; - N² 碰撞检测 + 响应;
- 对外只暴露
Step(dt),内部依赖Core→Vectors→Quaternions→Matrices。
- 应用全局重力、积分各
- 解耦策略:
PhysicsSystem仅持有接口FixedCollider,不管具体形状;- 碰撞体子类(AABB/Sphere/OBB)各自实现
Overlaps与ComputeManifold,利于扩展。
3.5 “回滚系统” 架构
namespace XFixedPoint.Networking
{
public class RollbackSystem<T>
{
void SubmitInput(int tick, T input);
void AdvanceTo(int targetTick, XFixed dt, Action<T> applyInput);
}
}
- 依赖:
- 引用
PhysicsSystem与Snapshot、InputBuffer。
- 引用
- 分层设计:
- **
Snapshot**:纯粹的二进制序列化状态,无业务逻辑; - **
InputBuffer**:按 tick 累积输入; - **
RollbackSystem**:核心回滚/重放逻辑,保证“一切状态恢复”可重现。
- **
4. 定点算术详解:加、减、乘、除
在完成模块划分与整体架构后,下一步就是打磨核心——最基础、最频繁使用的四则运算。“定点”在此处最直接体现在对整数位与小数位的一体化管理,以及对精度与性能的微妙平衡。
4.1 加法与减法:简洁、安全、零误差
public static XFixed operator +(XFixed a, XFixed b)
=> FromRaw(unchecked(a._raw + b._raw));
public static XFixed operator -(XFixed a, XFixed b)
=> FromRaw(unchecked(a._raw - b._raw));
为什么这么做?
- 位级对齐
- 定点数使用同一个“原始值”
_raw存储,只要保证相同格式(Q32.32),两数小数位天然对齐,比浮点运算省去了尾数对齐步骤。
- 定点数使用同一个“原始值”
- 零舍入误差
- 与整数运算等价,不会产生舍入;只要不溢出,结果完全精确。
- 性能最优
- 直接两个
long相加,相较浮点加法更轻量(无指数与尾数拆分)。
- 直接两个
溢出如何处理?
- C# 默认带溢出检查;
unchecked关闭检查以追求性能。 - 如果想严格捕获溢出,可在外层用
checked块或在XFixedAPI 中提供AddChecked等方法。
4.2 乘法:128 位中间,确保精度
public static XFixed operator *(XFixed a, XFixed b)
=> XFixedArithmetic.Multiply(a, b);
背后逻辑(伪代码):
BigInteger prod = (BigInteger)a._raw * b._raw;
// 原始相乘:rawA × rawB → 128 位中间
long resultRaw = (long)(prod >> SHIFT);
// 右移 f 位,将小数位“对齐”回原始位置
return FromRaw(resultRaw);
这样设计的好处
- 无精度丢失
- 任何
rawA × rawB的精确值都保留在 128 位中,右移再截断只丢弃真正低于最小量化单位的部分。
- 任何
- 位级一致
BigInteger计算与移位运算在所有平台都保证位级相同,不受 JIT 优化影响。
- 开发效率
- 一次实现,功能正确,后续可针对性能瓶颈单独优化。
为什么不手写 64×64→128 拆分?
- 可读性与安全性:手动拆分高低 32 位、合并时要考虑符号扩展、进位,高风险且不易维护。
- 优化时机:先验证正确性,再在需要的热点(如大量向量点乘)引入 SIMD 或查表加速,保持核心算法清晰。
4.3 除法:左移扩精,除零保护
public static XFixed operator /(XFixed a, XFixed b)
=> XFixedArithmetic.Divide(a, b);
核心(伪代码):
if (b._raw == 0) throw new DivideByZeroException();
// 先左移 f 位,放大被除数
BigInteger dividend = (BigInteger)a._raw << SHIFT;
long resultRaw = (long)(dividend / b._raw);
// 整除后即为对齐好的定点结果
return FromRaw(resultRaw);
设计思路
- 提升分子精度
- 左移保证小数计算不丢失,类似于 “浮点先 scale 然后标准除法” 的思路。
- 显式除零检查
- 在最底层抛异常,剥离业务层对“不合法输入” 的判断。
- 性能与精度平衡
- 大整数除法虽然慢一些,但只在必要时调用,比如
1/x、normalize、物理碰撞计算等;大量非除法场景不受影响。
- 大整数除法虽然慢一些,但只在必要时调用,比如
4.4 舍入模式与误差控制
- 截断舍入(toward zero):对乘、除的结果直接截断最低位,不做四舍五入,保证模拟行为可预测。
- 误差边界
- 乘法后最多丢失 1 小数单位:≈ 2⁻³² ≈ 2.3×10⁻¹⁰
- 除法后同理。
- 浮点对比
- 浮点乘/除误差通常为相对误差(ulp),定点是固定绝对误差,更易评估误差边界。
5. 高级数学函数:Sqrt、三角、Exp/Log
定点四则运算奠定了基础,但游戏/物理中离不开开方、三角函数和指数对数运算。
5.1 平方根(Sqrt):牛顿迭代
为什么不直接用二分法?
- 二分法 对定点也适用,但每次“中点运算”都要乘除,迭代次数多、收敛慢。
- 牛顿迭代 通常 4–6 步即可达到 Q32.32 精度。
实现要点
public static XFixed Sqrt(XFixed x)
{
if (x.Raw <= 0) return XFixed.Zero;
// 初始猜测:用位操作近似 x 的指数一半
long raw = x.Raw;
int exp = (int)(raw >> (SHIFT - 1)); // approximate exponent
XFixed guess = XFixed.FromRaw((1L << (SHIFT / 2)) << (exp / 2));
// 牛顿迭代: g_{n+1} = (g_n + x/g_n) / 2
for (int i = 0; i < 5; i++)
guess = (guess + x / guess) * XFixed.Half;
return guess;
}
- 初始猜测:通过位移提取近似指数,再构造
2^(exp/2),让迭代更快收敛。 - 迭代次数:5 步可以在 Q32.32 下保证误差 < 1 ULP。
- 边界处理:
x ≤ 0返回 0,避免除零。
5.2 三角函数:CORDIC 算法
为什么不直接查表?
- 查表 精度依赖表格大小,表过大内存占用高;表过小插值误差大。
- CORDIC 纯加减移位,无乘法,适合定点和硬件。
CORDIC 迭代原理(旋转模式)
预先计算一系列
atan(2⁻ⁱ)的定点常量表。从
(x₀=1, y₀=0, z₀=θ)开始,每步旋转 ±atan(2⁻ⁱ),更新:
if zₙ ≥ 0:
xₙ₊₁ = xₙ - (yₙ >> i)
yₙ₊₁ = yₙ + (xₙ >> i)
zₙ₊₁ = zₙ - atan_table[i]
else:
xₙ₊₁ = xₙ + (yₙ >> i)
yₙ₊₁ = yₙ - (xₙ >> i)
zₙ₊₁ = zₙ + atan_table[i]
- 最终
(x_N, y_N)≈(cos θ, sin θ),并乘上一个事先计算好的K = ∏ (1/√(1+2⁻²ⁱ))常量。
代码示例
static readonly XFixed[] AtanTable = { /* atan(2⁻⁰), atan(2⁻¹), ..., atan(2⁻ⁿ) */ };
static readonly XFixed CORDICK = /* ∏ 1/√(1+2⁻²ⁱ) precomputed */;
public static (XFixed sin, XFixed cos) CordicSinCos(XFixed theta)
{
XFixed x = CORDICK, y = XFixed.Zero, z = theta;
for (int i = 0; i < AtanTable.Length; i++)
{
bool dir = z.Raw >= 0;
XFixed dx = y >> i;
XFixed dy = x >> i;
x = dir ? x - dx : x + dx;
y = dir ? y + dy : y - dy;
z = dir ? z - AtanTable[i] : z + AtanTable[i];
}
return (y, x);
}
- 优点:无乘法,仅移位和加减,性能高且易定点化。
- 缺点:迭代次数决定精度,通常取 32 步保证 Q32.32 误差 < 1 ULP。
5.3 反三角与 Atan2
- 在
XFixedMath.Atan2(y, x)中,可先调用CordicAtan(已实现 atan 通用迭代),再根据象限调整。 Atan版本可用矢量模式 CORDIC:迭代压缩到一条线,从而只输出角度。
5.4 指数与对数:混合双精度
实现 Exp(x) 和 Log(x) 要求既有动态范围又要高效。常见策略:
- 分拆指数部分
x = N·ln2 + r,其中N = floor(x/ln2),r ∈ [−ln2/2, ln2/2]。exp(x) = 2^N × exp(r)。
- 多项式近似
- 在小区间
r上,用最小二乘或泰勒多项式近似eʳ或ln(1+r)。
- 在小区间
- 定点实现
- 先计算
N(整数运算) r转为XFixed,再用 Horner 法评估多项式(几次乘加)。2^N相当于<fixed raw> << N或>> N。
- 先计算
伪代码
public static XFixed Exp(XFixed x)
{
int N = (int)(x.ToDouble() / Ln2);
XFixed r = x - XFixed.FromDouble(N * Ln2);
// 用 5 阶泰勒:1 + r + r²/2! + … + r⁵/5!
XFixed y = EvaluatePoly(r);
// 左移或右移 N 位:y * 2^N
return y * XFixed.FromRaw(1L << N);
}
- 精度:多项式次数越高,区间越大,误差越小。
- 性能:每次需几次乘加,不如浮点一条指令,但可接受。
5.5 其它辅助:Clamp、Lerp、Pow
public static XFixed Clamp(XFixed x, XFixed min, XFixed max)
=> x < min ? min : (x > max ? max : x);
public static XFixed Lerp(XFixed a, XFixed b, XFixed t)
=> a + (b - a) * Clamp(t, XFixed.Zero, XFixed.One);
public static XFixed Pow(XFixed x, XFixed exp)
=> Exp(exp * Log(x));
- Clamp/Lerp:纯乘加,O(1),常用于插值与范围限制。
- Pow:复合函数,性能最低,但功能完备。
6. 向量、四元数与矩阵:构建物理仿真基石
在完成底层算术和高级数学函数后,接下来就要看“如何将这些函数拼装”成游戏物理常用的向量、旋转和矩阵运算了,来吧来吧。
6.1 定点向量:XFixedVector3
public readonly struct XFixedVector3 : IEquatable<XFixedVector3> {
public readonly XFixed X, Y, Z;
public XFixedVector3(XFixed x, XFixed y, XFixed z) {
X = x; Y = y; Z = z;
}
public static XFixedVector3 Zero => new XFixedVector3(XFixed.Zero, XFixed.Zero, XFixed.Zero);
public static XFixedVector3 UnitX => new XFixedVector3(XFixed.One, XFixed.Zero, XFixed.Zero);
public static XFixedVector3 UnitY => new XFixedVector3(XFixed.Zero, XFixed.One, XFixed.Zero);
public static XFixedVector3 UnitZ => new XFixedVector3(XFixed.Zero, XFixed.Zero, XFixed.One);
public static XFixedVector3 operator +(XFixedVector3 a, XFixedVector3 b)
=> new XFixedVector3(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
public static XFixedVector3 operator -(XFixedVector3 a, XFixedVector3 b)
=> new XFixedVector3(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
public static XFixedVector3 operator *(XFixedVector3 v, XFixed s)
=> new XFixedVector3(v.X * s, v.Y * s, v.Z * s);
public static XFixedVector3 operator /(XFixedVector3 v, XFixed s)
=> new XFixedVector3(v.X / s, v.Y / s, v.Z / s);
public XFixed Dot(XFixedVector3 o)
=> X * o.X + Y * o.Y + Z * o.Z;
public XFixedVector3 Cross(XFixedVector3 o)
=> new XFixedVector3(
Y * o.Z - Z * o.Y,
Z * o.X - X * o.Z,
X * o.Y - Y * o.X
);
public XFixed SqrMagnitude => Dot(this);
public XFixed Magnitude => XFixedMath.Sqrt(SqrMagnitude);
public XFixedVector3 Normalized {
get {
var mag = Magnitude;
return mag == XFixed.Zero
? Zero
: this / mag;
}
}
}
设计要点
- 操作符重载
- 保持与数学符号一致,阅读性高。
Normalized的安全性- 先检查
Magnitude == 0,避免除以零; - 返回
Zero而不是抛异常,更符合“零向量归一化得零向量”常用习惯。
- 先检查
- 精度与性能
Dot、Cross都是 O(1) 定点乘加运算;Magnitude调用Sqrt,迭代次数可控。
6.2 四元数:XFixedQuaternion
public readonly struct XFixedQuaternion : IEquatable<XFixedQuaternion> {
public readonly XFixed X, Y, Z, W;
public static XFixedQuaternion Identity => new XFixedQuaternion(XFixed.Zero, XFixed.Zero, XFixed.Zero, XFixed.One);
public XFixedQuaternion(XFixed x, XFixed y, XFixed z, XFixed w) {
X = x; Y = y; Z = z; W = w;
}
public XFixedQuaternion Conjugate => new XFixedQuaternion(-X, -Y, -Z, W);
public XFixedQuaternion Normalized {
get {
var mag = XFixedMath.Sqrt(X*X + Y*Y + Z*Z + W*W);
return mag == XFixed.Zero
? Identity
: new XFixedQuaternion(X/mag, Y/mag, Z/mag, W/mag);
}
}
public static XFixedQuaternion operator *(XFixedQuaternion a, XFixedQuaternion b)
=> new XFixedQuaternion(
a.W*b.X + a.X*b.W + a.Y*b.Z - a.Z*b.Y,
a.W*b.Y - a.X*b.Z + a.Y*b.W + a.Z*b.X,
a.W*b.Z + a.X*b.Y - a.Y*b.X + a.Z*b.W,
a.W*b.W - a.X*b.X - a.Y*b.Y - a.Z*b.Z
);
public XFixedVector3 Rotate(XFixedVector3 v) {
var qv = new XFixedQuaternion(v.X, v.Y, v.Z, XFixed.Zero);
var r = (this * qv * Conjugate).Normalized;
return new XFixedVector3(r.X, r.Y, r.Z);
}
public static XFixedQuaternion FromAxisAngle(XFixedVector3 axis, XFixed angle) {
var half = angle * XFixed.Half;
var s = XFixedMath.Sin(half);
var c = XFixedMath.Cos(half);
var n = axis.Normalized;
return new XFixedQuaternion(n.X*s, n.Y*s, n.Z*s, c);
}
}
解读与取舍
- 乘法实现
- 直接按四元数乘法公式实现,无需矩阵转换,兼顾性能与可读性。
Rotate方法- 使用
q * (v,0) * q⁻¹完整保留旋转性质; - 最后
.Normalized防止累积长度漂移。
- 使用
FromAxisAngle- 先标准化
axis,再构造(sinθ/2, cosθ/2); - 参数
angle单位为弧度,配合定点Sin/Cos。
- 先标准化
6.3 矩阵:XFixedMatrix4x4
public readonly struct XFixedMatrix4x4 : IEquatable<XFixedMatrix4x4> {
// 行优先 16 元素 M00…M33
public readonly XFixed M00, M01, M02, M03;
…
public static readonly XFixedMatrix4x4 Identity = new XFixedMatrix4x4(
XFixed.One, XFixed.Zero, XFixed.Zero, XFixed.Zero,
XFixed.Zero, XFixed.One, XFixed.Zero, XFixed.Zero,
XFixed.Zero, XFixed.Zero, XFixed.One, XFixed.Zero,
XFixed.Zero, XFixed.Zero, XFixed.Zero, XFixed.One);
public XFixedMatrix4x4(/*16 elements*/) { … }
public static XFixedMatrix4x4 operator *(XFixedMatrix4x4 a, XFixedMatrix4x4 b) { … }
public XFixedVector3 MultiplyPoint(XFixedVector3 p) {
// p.w = 1
var x = M00*p.X + M01*p.Y + M02*p.Z + M03;
var y = M10*p.X + M11*p.Y + M12*p.Z + M13;
var z = M20*p.X + M21*p.Y + M22*p.Z + M23;
var w = M30*p.X + M31*p.Y + M32*p.Z + M33;
return new XFixedVector3(x/w, y/w, z/w);
}
}
关键点
- 行优先存储
- 与 DirectX / Unity 矩阵布局一致,减少转置开销。
- 乘法实现
- 16×16 累加,全部定点乘加,O(64) 运算量。
MultiplyPoint- 支持齐次坐标
w除法,方便实现透视和仿射变换。
- 支持齐次坐标
MultiplyVectorw=0特殊分支,不做除法,用于法线或方向向量。
6.4 在物理仿真中的应用
- 刚体积分:平移
Position += Velocity * dt,旋转Rotation = Δq * Rotation; - 碰撞检测:OBB vs. OBB 用
Quaternion.Rotate提取世界轴; - 坐标变换:将局部顶点批量
MultiplyPoint到世界空间; - 插值:
Vector3.Lerp、Quaternion.Slerp中底层用定点乘加和Atan2计算权重。
7. 物理仿真框架:刚体、碰撞与响应
完成基础数学与几何结构后,下一大板块就是物理仿真——如何让角色受力、碰撞并做出合理反应。
接下来就是刚体和碰撞体以及物理系统的实现,定点环境下的动力学模拟可离不开它们。
7.1 刚体积分:FixedRigidbody
public class FixedRigidbody
{
public XFixedVector3 Position;
public XFixedQuaternion Rotation;
public XFixedVector3 Velocity;
public XFixedVector3 AngularVelocity;
public XFixed Mass { … } // 设置时自动更新 InverseMass
public XFixed InverseMass { get; }
private XFixedVector3 _forceAcc; // 本帧累积的外力
private XFixedVector3 _torqueAcc; // 本帧累积的扭矩
public void AddForce(XFixedVector3 f) => _forceAcc += f;
public void AddTorque(XFixedVector3 τ) => _torqueAcc += τ;
public void ClearAccumulators() { _forceAcc = _torqueAcc = XFixedVector3.Zero; }
public void Integrate(XFixed dt)
{
if (dt == XFixed.Zero || Mass == XFixed.Zero) { ClearAccumulators(); return; }
// —— 线性积分 ——
var accel = _forceAcc * InverseMass; // a = F/m
Velocity += accel * dt; // v += a·dt
Position += Velocity * dt; // x += v·dt
// —— 角运动 —— (简化的标量惯量)
var α = _torqueAcc * InverseMass; // α ≈ τ / I
AngularVelocity += α * dt;
var ωmag = AngularVelocity.Magnitude;
if (ωmag != XFixed.Zero)
{
var axis = AngularVelocity / ωmag;
var deltaQ = XFixedQuaternion.FromAxisAngle(axis, ωmag * dt);
Rotation = (deltaQ * Rotation).Normalized;
}
ClearAccumulators();
}
}
设计解析
- 显式欧拉
- 简单、易实现;累积误差可受定点积分零漂移控制。
- 对刚体旋转使用“轴–角增量”近似,避免复杂四元数微分。
- 质量与倒数
InverseMass预先计算,避免每帧做1/m。- 质量为零时视为“静态”或“运动学”刚体,仅响应位置/旋转外部设置,不参与积分。
- 累积力/力矩
- 一帧内多次
AddForce/AddTorque,最后一起积分;清零后准备下一帧。 - 确保“∆v = ∑F · dt / m”,而非“∑(F/m · dt)”累积舍入。
- 一帧内多次
- 旋转更新
- 将角速度向量分解为“轴”和“角度”,构造四元数增量
δq,左乘旧旋转并正规化。 - 正规化防止舍入累积让四元数不再单位长度。
- 将角速度向量分解为“轴”和“角度”,构造四元数增量
7.2 碰撞体抽象:FixedCollider
public abstract class FixedCollider
{
public FixedRigidbody Rigidbody { get; set; }
public XFixedVector3 LocalOffset { get; set; } = Zero;
public XFixedQuaternion LocalRotation { get; set; } = Identity;
public XFixedVector3 WorldPosition => Rigidbody == null
? LocalOffset
: Rigidbody.Position + Rigidbody.Rotation.Rotate(LocalOffset);
public XFixedQuaternion WorldRotation => Rigidbody == null
? LocalRotation
: (Rigidbody.Rotation * LocalRotation).Normalized;
public abstract bool Overlaps(FixedCollider other);
public abstract bool ComputeManifold(FixedCollider other, out CollisionManifold m);
}
7.3 具体碰撞器:AABB、球体、OBB
1. AABB (Axis-Aligned Bounding Box)
1.1 技术分析
定义
$$盒子中心 C,半尺寸 H=(h_x,h_y,h_z).$$
沿三轴的投影区间:
$$[C_x - h_x,C_x + h_x],[C_y - h_y,C_y + h_y],[C_z - h_z,C_z + h_z].$$
碰撞判定
- $$对每轴 u\in{x,y,z},区间重叠当且仅当A_{\min u} \le B_{\max u}\quad\land\quad A_{\max u} \ge B_{\min u}.$$
- $$三轴同时重叠 \implies AABB 相交.$$
穿透深度
$$d_u = \min\bigl(A_{\max u},B_{\max u}\bigr) - \max\bigl(A_{\min u},B_{\min u}\bigr).$$
碰撞法线
$$令 u^* = \underset{u\in\{x,y,z\}}{\arg\min}d_u$$
$$法线方向为单位向量 \mathbf{e}_{u^*}$$
$$符号由 (B.C_{u^*} - A.C_{u^*}) 的正负决定.$$
接触点
$$P_{u^*} = \frac{\max\bigl(A_{\min u^*},B_{\min u^*}\bigr) + \min\bigl(A_{\max u^*},B_{\max u^*}\bigr)}{2}.$$
1.2 C# 实现
private static bool OverlapsAABBvsAABB(AABBCollider a, AABBCollider b)
{
var amin = a.WorldPosition - a.HalfSize;
var amax = a.WorldPosition + a.HalfSize;
var bmin = b.WorldPosition - b.HalfSize;
var bmax = b.WorldPosition + b.HalfSize;
return
amin.X <= bmax.X && amax.X >= bmin.X &&
amin.Y <= bmax.Y && amax.Y >= bmin.Y &&
amin.Z <= bmax.Z && amax.Z >= bmin.Z;
}
private static CollisionManifold ComputeAABBvsAABB(AABBCollider a, AABBCollider b)
{
var amin = a.WorldPosition - a.HalfSize;
var amax = a.WorldPosition + a.HalfSize;
var bmin = b.WorldPosition - b.HalfSize;
var bmax = b.WorldPosition + b.HalfSize;
var dx = XFixedMath.Min(amax.X, bmax.X) - XFixedMath.Max(amin.X, bmin.X);
var dy = XFixedMath.Min(amax.Y, bmax.Y) - XFixedMath.Max(amin.Y, bmin.Y);
var dz = XFixedMath.Min(amax.Z, bmax.Z) - XFixedMath.Max(amin.Z, bmin.Z);
if (dx <= XFixed.Zero || dy <= XFixed.Zero || dz <= XFixed.Zero)
return new CollisionManifold { Colliding = false };
var penetration = dx;
var normal = new XFixedVector3(XFixed.One, XFixed.Zero, XFixed.Zero);
if (dy < penetration) { penetration = dy; normal = new XFixedVector3(0,1,0); }
if (dz < penetration) { penetration = dz; normal = new XFixedVector3(0,0,1); }
var diff = b.WorldPosition - a.WorldPosition;
normal = (diff.Dot(normal) < XFixed.Zero) ? -normal : normal;
var contact = new XFixedVector3(
(XFixedMath.Max(amin.X,bmin.X) + XFixedMath.Min(amax.X,bmax.X)) * XFixed.Half,
(XFixedMath.Max(amin.Y,bmin.Y) + XFixedMath.Min(amax.Y,bmax.Y)) * XFixed.Half,
(XFixedMath.Max(amin.Z,bmin.Z) + XFixedMath.Min(amax.Z,bmax.Z)) * XFixed.Half
);
return new CollisionManifold {
Colliding = true,
Normal = normal,
PenetrationDepth = penetration,
ContactPoint = contact
};
}
2. Sphere 碰撞
2.1 技术分析
定义:
$$中心 c_A,c_B,半径 r_A,r_B.$$
相交判定:
$$||\Delta||^2 = (\Delta x)^2+(\Delta y)^2+(\Delta z)^2 \le(r_A+r_B)^2.$$
法线:
$$n = \frac{\Delta}{||\Delta||}, \quad \Delta = c_B - c_A.$$
$$若 ||\Delta||=0,任选单位向量.$$
穿透深度:
$$\delta = r_A + r_B - ||\Delta||.$$
接触点:
$$p = c_A + n \bigl(r_A - \tfrac{\delta}{2}\bigr).$$
2.3 C# 实现
private static CollisionManifold ComputeSphereVsSphere(SphereCollider a, SphereCollider b)
{
var centerA = a.WorldPosition;
var centerB = b.WorldPosition;
var diff = centerB - centerA;
var distSq = diff.Dot(diff);
var rSum = a.Radius + b.Radius;
if (distSq > rSum * rSum)
return new CollisionManifold { Colliding = false };
var dist = XFixedMath.Sqrt(distSq);
var normal = (dist == XFixed.Zero)
? new XFixedVector3(XFixed.One, 0, 0)
: diff / dist;
var penetration = rSum - dist;
var contact = centerA + normal * (a.Radius - penetration * XFixed.Half);
return new CollisionManifold {
Colliding = true,
Normal = normal,
PenetrationDepth = penetration,
ContactPoint = contact
};
}
3. OBB (Oriented Bounding Box)
3.1 技术分析(分离轴定理)
轴集:
- $$A 本地轴 \mathbf{A}_0,\mathbf{A}_1,\mathbf{A}_2$$
- $$B 本地轴 \mathbf{B}_0,\mathbf{B}_1,\mathbf{B}_2$$
- $$交叉轴 \mathbf{A}_i \times \mathbf{B}_j$$
半投影长度:
$$R_A(L) = \sum_{i=0}^2 h_{A,i}\bigl|\mathbf{A}_i \cdot \hat{L}\bigr| /2,B同理$$
中心投影距:
$$D = \bigl|(\mathbf{c}_B - \mathbf{c}_A)\cdot \hat{L}\bigr|.$$
判定:
$$若 \exists L: D > R_A+R_B ⇒ 无碰撞;否则相交。$$
穿透轴:
$$最小 (R_A+R_B - D) 对应的 L。$$
接触点:
$$\tfrac{1}{2}\bigl(\mathrm{Support}_A(-L) + \mathrm{Support}_B(L)\bigr).$$
3.3 C# 实现(片段)
// 1. 计算本地轴
var axesA = new[]{
a.WorldRotation.Rotate(XFixedVector3.UnitX),
a.WorldRotation.Rotate(XFixedVector3.UnitY),
a.WorldRotation.Rotate(XFixedVector3.UnitZ)
};
var axesB = …;
// 2. 构建 R 与 absR
var R = new XFixed[3,3];
var absR = new XFixed[3,3];
var eps = XFixed.FromRaw(XFixedConstants.EPS);
for (int i=0; i<3; i++)
for (int j=0; j<3; j++){
R[i,j] = axesA[i].Dot(axesB[j]);
absR[i,j] = XFixedMath.Abs(R[i,j]) + eps;
}
// 3. 中心投影
var tVec = b.WorldPosition - a.WorldPosition;
var tA = new XFixed[3];
for (int i=0; i<3; i++) tA[i] = tVec.Dot(axesA[i]);
// 4. SAT 测试 & 最小穿透
XFixed bestPen = XFixed.FromRaw(long.MaxValue);
XFixedVector3 bestAxis = XFixedVector3.Zero;
// A 本地轴
for (int i=0; i<3; i++){
var ra = (i==0 ? a.HalfSize.X : i==1 ? a.HalfSize.Y : a.HalfSize.Z);
var rb = a.HalfSize.X*absR[i,0] + a.HalfSize.Y*absR[i,1] + a.HalfSize.Z*absR[i,2];
var D = XFixedMath.Abs(tA[i]);
var pen= (ra+rb) - D;
if (pen < XFixed.Zero) return noCollision;
var axis = axesA[i] * (tA[i]<XFixed.Zero ? -XFixed.One : XFixed.One);
if (pen < bestPen){ bestPen=pen; bestAxis=axis; }
}
// … 同理 B 轴与交叉轴 …
// 5. 接触点
var supportA = Support(a, -bestAxis);
var supportB = Support(b, bestAxis);
var contact = (supportA + supportB) * XFixed.Half;
return new CollisionManifold {
Colliding = true,
Normal = bestAxis,
PenetrationDepth = bestPen,
ContactPoint = contact
};
7.4 物理系统:PhysicsSystem
public class PhysicsSystem
{
List<FixedRigidbody> _bodies;
List<FixedCollider> _colliders;
XFixedVector3 Gravity = new(0, -9.81, 0);
XFixed Restitution = XFixed.FromFloat(0.5f);
public void AddBody(FixedRigidbody b, FixedCollider c = null) { … }
public void Step(XFixed dt)
{
// 1. 重力
foreach (b in _bodies) b.AddForce(Gravity * b.Mass);
// 2. 积分
foreach (b in _bodies) b.Integrate(dt);
// 3. 碰撞检测与响应(N²)
for (i=0; i<…; i++)
for (j=i+1; j<…; j++)
if (A.ComputeManifold(B, out m) && m.Colliding)
ResolveCollision(A.Rigidbody, B.Rigidbody, m);
}
}
响应逻辑
- 位置校正
- 按逆质量比例将重叠沿法线分离,消除穿透。
- 冲量反冲
- 计算相对速度沿法线分量
v_rel = (vB−vA)·n, - 冲量
j = −(1+e)·v_rel / (invM_A + invM_B), - 更新速度
vA -= j·n·invM_A,vB += j·n·invM_B。
- 计算相对速度沿法线分量
7.5 设计取舍与优化
因为写这么一个库还挺累的,有些能优化的工作就暂时放到后面做个TODO吧……
| 环节 | 方案 | 权衡与优化方向 |
|---|---|---|
| 积分方式 | 显式欧拉 | 简单易用;如需高精度可考虑 Verlet 或 RK4 |
| 碰撞检测 | N² 遍历 | 对少量刚体足够;大规模可引入空间分区(四叉树、BVH) |
| OBB 算法 | SAT(12 轴测试) | 精确;性能可用 SSE 加速或提前剔除平行轴 |
| 物理参数 | 简化惯性张量(标量质量) | 可扩展到 3×3 矩阵惯性张量 |
| 多线程 | 单线程 | Unity Job / Burst 可并行检测和积分 |
8. 网络帧同步与回滚系统
在多人实时对战中,除了本地物理模拟的一致性,网络延迟 和 包乱序 带来的输入时序错位是最大挑战。传统的“确认-应用”模型会导致玩家间画面不同步或卡顿;而回滚(Rollback)技术能在收到网络延迟输入后,自动“回退”到当时帧状态,重新播放后续所有帧,恢复确定性。
8.1 为什么需要回滚?
- 延迟补发
- 玩家输入发出后未必及时到达对端;如果不回滚,后续帧会缺少该输入,导致逻辑偏差。
- 一致性保证
- 帧同步逻辑假设“同样的输入序列必定产生同样的状态”,回滚+重演确保即使落后输入到来,也能恢复到对应点并重演。
- 流畅体验
- 本地策略:本地输入立即生效(Client-Side Prediction),远端输入延迟到来时回滚并重演,看上去更连贯。
8.2 核心组件
XFixedPoint.Networking/
├─ InputBuffer.cs ← 按 tick 缓存多条输入
├─ RollbackSystem.cs ← 回滚检测、状态恢复与重演
└─ Snapshot.cs ← 二进制快照序列化与恢复
8.2.1 Snapshot
- 职责:将一整帧所有刚体状态“按顺序”序列化为字节数组;恢复时按同样顺序反序列化回刚体列表。
- 实现细节:
- 固定顺序——快照时按
_bodies列表顺序写入每个刚体的Position.Raw、Rotation.Raw、Velocity.Raw、AngularVelocity.Raw。 - 位级一致——直接写入
long,保证二进制完全可还原,无浮点差异(示例代码见Snapshot.Create/Restore)。
- 固定顺序——快照时按
- 设计取舍:
- 使用
BinaryWriter写原始long,比 JSON、XML 轻量且无格式歧义; - 全快照简单易实现,但内存与带宽占用大;可扩展为“增量快照”或“差分快照”以节省空间。
- 使用
8.2.2 InputBuffer<TInput>
- 职责:收集并存储到达时序不定的所有玩家输入,按
tick(帧号)分桶缓存,多条输入不被覆盖。 - 实现要点:
- 内部用
SortedDictionary<int, List<TInput>>存储:键为tick,值为该帧所有输入列表。 AddInput(tick, input):新增一条,不覆盖;TryGetInputs(tick, out List<TInput>):获取本帧所有输入;RemoveOld(beforeTick):丢弃早已确认后不再回滚的帧输入,释放内存。
- 内部用
- 为什么不只存一条?
- 一帧可能同时收到本地与远端多条操作;
- 网络补发机制可能在同一帧内到来多次,需要全部重演。
8.2.3 RollbackSystem<TInput>
public class RollbackSystem<TInput>
{
void SubmitInput(int tick, TInput input);
void AdvanceTo(int targetTick, XFixed dt, Action<TInput> applyInput);
}
核心流程
- 检测延迟输入(伪代码)
- 找到所有已模拟帧中回来的“晚到输入”,回滚到最早那帧。
earliestLate = min(tick ≤ _lastAppliedTick from InputBuffer.Ticks)
if exists:
Restore snapshot at earliestLate
_lastAppliedTick = earliestLate - 1
- 重演重播(伪代码)
- 先清零速度:本 Demo 中每帧速度由输入决定,不累积前帧速度;
- 逐条调用applyInpu:可以是设定刚体速度、触发技能、发弹道等;
- 物理步进 保证每帧状态准确。
for tick in (_lastAppliedTick+1)..targetTick:
SaveSnapshot(tick)
// —— 本帧重置所有刚体速度 ——
bodies.ForEach(b => b.Velocity = Vector3.Zero)
// 应用本帧**所有**输入
if InputBuffer.TryGetInputs(tick, out inputs):
foreach inp in inputs: applyInput(inp)
physics.Step(dt)
_lastAppliedTick = tick
- 清理历史
- 保留最近 N 帧快照/输入(通常 N=200);
- 自动删除更老数据,防止内存或磁盘无限增长。
设计考量
- 全帧快照 简单、易实现;缺点是占用高。
- 增量快照/状态压缩 可减小内存与网络压力,但实现复杂。
- 多输入支持 让一帧内多个玩家或补发操作都能正确重演。
- 回滚边界:若输入延迟超过保留历史,需触发“网络重同步”或“状态快照传输”机制。
8.3 示例:MoveOp 的回滚重演
// 每帧 Update 中:
var op = new MoveOp { Tick = curTick, PlayerIndex = idx, RawX = ..., RawZ = ... };
rollback.SubmitInput(curTick, op);
client.SendMoveOp(op);
rollback.AdvanceTo(curTick, dt, op => {
// 重置后再应用:设置刚体速度 = dir * speed
bodies[op.PlayerIndex].Velocity = new Vector3(op.RawX, 0, op.RawZ) * speed;
});
- 本地预测 + 网络广播:本地立刻生效,远端输入到来时全局回滚重演。
AdvanceTo保证所有玩家输入“准时”按帧执行,画面保持一致。
终于暂时告一段落了,这文档可太难写了,希望能够帮助到各位吧。
有问题或者优化也欢迎来交流,过段时间应该会上传到github上,上传完后会把连接加到文章末尾,后面推荐几个 文章/视频/库 给想学习计算机图形学、网络同步技术的小伙伴
推荐阅读:
Computerphile: “Floating Point Numbers”
简明动画演示浮点 sign/exponent/mantissa,以及“0.1+0.2≠0.3”产生原因
https://www.youtube.com/watch?v=p8u_k2LIZyoLecture 68: Fixed Point Arithmetic and Concept of Q Format
讲解 Q 格式与定点加减乘除规则。
https://www.youtube.com/watch?v=lVa-AwaHbDQFixed Point Maths Explained – Retro Programming
用“钱币示例”演示定点数的工作原理与误差。
https://www.youtube.com/watch?v=Is67DfCdvcEReal-Time Collision Detection
这本书我个人其实还挺推荐的,覆盖的内容听广泛的,不管是数学还是碰撞检测还是空间划分,亦如GPY加速和数值计算也有;不过由于文章年代过于久远,所以其中GPU加速等内容已经过时了。。。把视角汇聚在数学思维上吧
Rollback Netcode Explained in 3 Minutes
视频讲解Rollback 核心流程:本地预测、延迟补发、回滚重演。
Why Rollback Netcode Is Better
对比延迟型 vs. 回滚型网络同步,讲解用户体验与技术逻辑。
GGPO
C++的实时在线游戏体验的开源SDK
Photon Quantum
Github: XFixedPointLibrary