Microfacet

Microfacet Theory - Physically Based Rendering

Roughness Using Microfacet Theory - PBRT

引入

物体在宏观上有一个法线$n$,而由于真实的表面都是坑洼不平的,对于微观的一束光,其会命中表面的一个倾斜的片(微表面)而反射出去,这个倾斜面片的法线往往和宏观的$n$方向不同。对于同一个表面从大量的光的统计上来看,越粗糙偏离的也就越大。

当然,如果我们有一个非常强悍的机器,我们可以做一个分辨率很高的模型,其表面具有真正坑坑洼洼的三角形,但是这样做时间、空间的效率都很差。

有意思的是,大量的微表面可以在统计学意义上被建模,从而让基于微表面理论的BSDF不用再受制于上面提到的问题。

光学模型

3f5820f89c72adf112fd9bf03543c6dc.png
图片展示了三种光学行为
(a): 遮挡,相机看不到被照亮的地方,被其他的微平面挡住了
(b): 阴影,相机看到的地方没有光的照射,因为光线被其他的微平面挡住了
(c): 来回反射(Interreflection), 光线在微表面之间反射,最终能被相机看到

广泛使用的微表面 BSDFs 忽略来回反射。其主要由两个部分组成,一是微表面朝向的统计分布,二是描述光线如何从单个微表面散射的 BSDF

假设光源和观察者相对于微表面的尺度而言足够远的前提下,精确的表面轮廓(那些微表面的具体形状)对于遮蔽和阴影的影响相对较小。

数学推导

微表面分布项 $D(\omega_m)$

我们先不考虑遮挡和阴影,我们首先讨论法线和粗糙度的关系,而我们需要知道有多少比例的微观法线指向了某个特定方向。

我们定义$D(\omega_m)$给出的是法线朝向为 $\omega_m$ 的微表面 (microfacets) 的相对微分面积给 $D$ 一个方向($\omega_m$),它会告诉你“有多少(宏观面积一个 $dA$中的)微观法线正指向这个方向

所有到达 $dA$ 这个小区域的入射光线($\omega_i$)都是互相平行的。
所有离开 $dA$ 这个小区域的出射光线($\omega_o$)也都是互相平行的

其有如下关系:
$\int_{H^2(\bold{n})} D(\omega_m) (\omega_m \cdot \bold{n}) d\omega_m = \int_{H^2(\bold{n})} D(\omega_m) \cos \theta_m d\omega_m = 1$
表示一块粗糙的表面,无论它内部多么凹凸不平,它在宏观世界(比如被太阳光照射)的总‘占地面积’是不会变的。

注意,这里的 $\bold{n}$ 在标准反射坐标系(standard reflection coordinate system)下,有 $\bold{n} = (0,0,1)$, 那么 $\omega_m \cdot \bold{n}$ 等价于 $\cos \theta_m$

$\int_{H^2(\bold{n})} D(\omega_m) \cos \theta_m d\omega_m = 1$

  • $\int_{H^2(\bold{n})} \dots d\omega_m$ 检查所有($\int$)可能的倾斜方向($d\omega_m$)
  • $D(\omega_m)$ 朝向 $\omega_m$ 这个方向的微表面占比是多少
  • $\theta_m$ 是微表面的法线 $\omega_m$ 和“底座”法线 $\bold{n}$(垂直向上)的夹角 $\cos \theta_m$ 描述了其(投影在宏观上的)投影的面积drawio
  • $D(\omega_m) \times \cos \theta_m$ 表示所有朝向 $\omega_m$ 的木片,它们在平坦底座上的‘投影’总面积
  • $\int \dots = 1$ 表示所有投影面积的总和,必须等于 1

最常见的微表面分布类型是各向同性的 (isotropic),这也会导出一个各向同性的聚合 BSDF。
回想一下,当表面绕着宏观表面法线旋转时,一个各向同性的 BSDF 其局部的散射特性是不会改变的

在各向同性的情况下,使用球坐标参数 $\omega_m = (\theta_m, \phi_m)$ 会得到一个只依赖于仰角 $\theta_m$ 的分布。

8621c1e69cf522632db0265653485bc9.png

d56443e022dcdea9dd29d38b303e4b49.png
X 轴 ($\theta_m$): 微观法线的倾斜角度
Y 轴 ($D(\omega_m)$): 法线的数量(密度)

“长尾”(来自 $\theta_m$ 很大的法线)会捕捉到光,并将其散射开,形成一个非常宽广、非常柔和的“朦胧光晕”

具体实现

那么我们怎么具体去实现D的分布呢?不同的论文有不同的实现,目前比较主流的是GGX与 Disney 实现。
注意,在 D F G 三项中,一般F是通用的,而D与G项配对使用

GGX中的$D(\omega_m)$

$D(\omega_m) = \frac{1}{\pi \alpha_x \alpha_y \cos^4 \theta_m \left( 1 + \tan^2 \theta_m \left( \frac{\cos^2 \phi_m}{\alpha_x^2} + \frac{\sin^2 \phi_m}{\alpha_y^2} \right) \right)^2}$
其中,

  • $\omega_m$ 是微观法线方向
  • $\theta_m$ 是微观法线 $\omega_m$ 与宏观法线 $\bold{n}$ 之间的仰角
  • $\phi_m$ 是微观法线 $\omega_m$ 绕着宏观法线 $\bold{n}$ 旋转的方位角
  • $\alpha_x$ 和 $\alpha_y$ 是分别控制两个切线方向(x 和 y)的粗糙度参数
    可以看出,$\alpha_x$ 和 $\alpha_y$ 这两个是属于材质的参数。
三种情况
  1. $\alpha_u = \alpha_v = 0$ (完美光滑)

    • $1/\alpha_u$ 和 $1/\alpha_v$ 都是无限大。
    • 这意味着那个椭球体在 U 和 V 两个方向上被无限拉伸。
    • 一个被无限拉伸的球体是什么?就是一个无限平坦的平面。
    • 结果: 完美镜面反射。
  2. $\alpha_u = \alpha_v$ (各向同性 / Isotropic)

    • U 方向的粗糙度和 V 方向的粗糙度相等。
    • 微观椭球体被均匀拉伸,变成了球形或“煎饼”形。
    • 结果: 这是一个均匀粗糙的表面,比如磨砂塑料。你绕着法线旋转它,高光不会变。这就是“方位角依赖性消失了”的意思。
  3. $\alpha_u \neq \alpha_v$ (各向异性 / Anisotropic)

    • U 方向的粗糙度和 V 方向的粗糙度不相等。
    • 例如,$\alpha_u = 0.5$ 但 $\alpha_v = 0.1$。
    • 这意味着椭球体在 U 方向上“比较粗糙”(没怎么拉伸),但在 V 方向上“比较光滑”(被拉伸得很多)。
    • 结果: 这就是“雪茄”形,它在微观上形成了“凹槽”。这就是有方向的粗糙,比如拉丝金属或木纹
特性

GGX 模型正确地指出:即使在粗糙的表面上,也存在着大量非常倾斜($\theta_m$ 很大)的微观法线

核心的高光(来自 $\theta_m$ 较小的法线)依然明亮且集中

“长尾”(来自 $\theta_m$ 很大的法线)捕捉到的光,会向四周散射,形成一个非常宽广、非常柔和的“朦胧光晕”

遮蔽项 The Masking Function $G_1(\omega, \omega_m)$

仅有 D 项的话是做不到能量守恒的 BSDF 的。因为从一个特定角度看的时候,不是所有的法线面向这个角度的微平面的面积都会被看到,我们必须避免非物理的能量引入。

我们定义 $G_1(\omega, \omega_m)$,它是一个可见度百分比(一个 0 到 1 之间的折扣因子)
对于所有朝向 $\omega_m$ 的微观法线,当我们从 $\omega$ 方向(可能是观察者方向 $\omega_o$,也可能是光源方向 $\omega_i$)去看它们时,有多少百分比 (fraction) 是没有被挡住的?

$D$ 无法确定 $G_1$ 因为我们无法知道$D$ 给出的法线分布是如何排列的,比如情况 A: 所有的 60° 陡坡都聚集在一起,形成了一面巨大的悬崖。这会投下巨大的阴影。($G_1$ 会很小)。情况 B: 所有的 60° 陡坡都均匀地、零散地分布在平地中。这只会投下很小的阴影。($G_1$ 会比较大)

为了便于计算,我们假设是情况B,也就是Smith近似。

$G_1 = 1$ 意味着百分百可见,而$G_1 = 0$意味着0% 可见
c3b8b96b55f4ad9a660a2684045bf808.svg
从观察者或光源的角度看,表面上的一个微分区域 $dA$ 所呈现的投影面积 (projected area) 为 $dA \cos \theta$,其中 $\theta$ 是该(观察/入射)方向与表面法线 $\bold{n}$ 之间的夹角。

(所有)可见微观表面(图中的粗线)的投影表面积加起来,也必须等于 $dA \cos \theta$。遮蔽函数 $G_1$ 给出的就是 $dA$ 上方总微观表面面积中,在给定方向上可见的比例

数学推导

$\int_{H^2(\bold{n})} D(\omega_m) G_1(\omega, \omega_m) \max(0, \omega \cdot \omega_m) d\omega_m = \omega \cdot \bold{n} = \cos \theta$

  • $\int_{H^2(\bold{n})} \dots d\omega_m$ 检查所有($\int$)可能的倾斜方向($d\omega_m$)

  • $D(\omega_m)$ 朝向 $\omega_m$ 这个方向的微表面占比是多少

  • $\omega$ 表示想用来测试这个表面的方向

  • $G_1(\omega, \omega_m)$ 在所有法线朝向 $\omega_m$ 的微表面中,当我们从方向 $\omega$(比如相机方向)看过去时,有多少比例 (fraction) 是没有被其他微表面挡住的

  • $\max(0, \omega \cdot \omega_m)$ 既负责剔除也负责投影

    • 剔除 (Culling): $\omega \cdot \omega_m$ 是观察方向 $\omega$ 和微观法线 $\omega_m$ 的点积。如果这个值小于 0(即 $\omega_m$ 背向 $\omega$),$\max(0, \dots)$ 会强制将它变为 0,因为它无论如何都不可见
    • 投影 (Projection): 如果这个值大于 0($\omega_m$ 面向 $\omega$),这个点积(即 $\cos \theta_{\omega, \omega_m}$)本身就是一个投影因子。它计算的是这个微观表面 $\omega_m$ 在垂直于 $\omega$ 方向的平面上的“剪影”面积

在上述的公式中,$G_1(\omega, \omega_m)$ 依赖于积分变量$\omega_m$, 而当我们做了 Smith 假设(高度和法线在统计上是独立的)之后,$G_1$ 将不再依赖于它自己的围观法线,其将只依赖于观察方向$\omega$和表面的整体粗糙度。也就是

$$
G_1(\omega, \omega_m) \approx G_1(\omega)
$$

从而,我们得到:

$$
\int_{H^2(\bold{n})} D(\omega_m) G_1(\omega) \max(0, \omega \cdot \omega_m) d\omega_m = \cos \theta
$$

也就是

$$
G_1(\omega) = \frac{\cos \theta}{\int_{H^2(\bold{n})} D(\omega_m) \max(0, \omega \cdot \omega_m) d\omega_m}
$$

虽然上式有解析解,但是为了简便,我们引入坡度域的辅助函数:

$$
G_1(\omega) = \frac{1}{1 + \Lambda(\omega)}
$$

$\Lambda$ 函数(我们之前用它来算 $G_1$)本身就代表了从一个方向看的“不可见”程度

$$
\Lambda(\omega) = \frac{\sqrt{1 + \alpha^2 \tan^2 \theta} - 1}{2}
$$

其中

$$
\alpha = \sqrt{\alpha_x^2 \cos^2 \phi + \alpha_y^2 \sin^2 \phi}
$$

The Masking-Shadowing Function $G$

BSDF 是一个有两个方向参数($\omega_i$ 和 $\omega_o$)的函数,并且每个方向都受到表面微观结构引起的遮挡效应的影响。
对于观察方向 ($\omega_o$) 和光照方向 ($\omega_i$),这些(遮挡效应)分别被称为遮蔽 (masking) 和阴影 (shadowing)

为了同时处理这两种情况,(单个方向的)遮蔽函数 $G_1$ 必须被推广为一个遮蔽-阴影函数 (masking-shadowing function) $G_2$,它($G_2$)给出的是(某个)微分区域中,同时 (simultaneously) 从 $\omega_i$ 和 $\omega_o$ 两个方向都可见的微表面的比例 (fraction)

如果我们假设遮蔽 (masking) 和阴影 (shadowing) 是统计上独立的 (statistically independent) 事件,那么这两个概率就可以简单地相乘:

$$
G_2(\omega_i, \omega_o) = G_1(\omega_i) G_1(\omega_o)
$$

这种假设将会刚高估阴影和遮蔽的实际数量,会导致在最终渲染的图片上出现暗区。因为这两个事件其实在统计学上并不是独立的,而是相关的。一个微表面要么倾向于对“两边”都可见,要么倾向于对“两边”都不可见。 它高估了“一个可见的表面又恰好被另一个挡住”的概率。它错误地把两个 80% 乘在了一起(得到了 64%),而真实的“同时可见”比例($G_2$)可能要高得多(比如 75%)

为了修复这个问题,我们引入新公式

$$
G_2 = \frac{1}{1 + \Lambda(\omega_i) + \Lambda(\omega_o)}
$$

  • 它不再是“概率相乘”($G_1 \times G_1$)
  • 它变成了“不可见量相加”

采样

现在我们考虑当一条光线从 $\omega_i$ 方向射入一个粗糙表面时,它接下来应该‘弹’向哪个随机的方向 $\omega_o$

  • 光线会“弹”向哪里,取决于它击中了哪一个微观法线 $\omega_m$

  • 它不能用一个“公平的”骰子(比如在所有方向上均匀抽样),因为我们知道,击中某些 $\omega_m$(比如那些正对着它的)的概率要高得多

一条光线与某个特定微表面相互作用的概率,是与它的可见面积 (visible area) 成正比的。换句话说,一束光(或视线)击中某个微表面的概率,正比于那个微表面向它呈现的可见投影面积

原公式(确保所有“可见”微表面投影的总面积是 $\cos \theta$ * $dA$)

$$
\int_{H^2(\bold{n})} D(\omega_m) G_1(\omega) \max(0, \omega \cdot \omega_m) d\omega_m = \cos \theta
$$

这是所有法线朝向 $\omega_m$(附近 $d\omega_m$ 范围内)的可见微表面,它们所贡献的投影面积总和:

$$
D(\omega_m) G_1(\omega) \max(0, \omega \cdot \omega_m) d\omega_m
$$

当一束光(或我们的视线)从方向 $\omega$ 射向这块表面时,它击中一个法线朝向为 $\omega_m$ 的微表面的(法线空间)概率是多少?:

$$
D_\omega(\omega_m) = \frac{G_1(\omega) D(\omega_m) \max(0, \omega \cdot \omega_m)}{\cos \theta} \quad
$$

$$
D_\omega(\omega_m) = \frac{\text{朝向 }\omega_m\text{ 的可见投影面积}}{\text{所有可见投影面积的总和}}
$$

也就是我们给$D$一个向量,其会返回 $\omega_m$ 被击中的概率是 $X$

而Sample() (采样算法)是“骰子”

  • 作用这是一个生成函数。
  • 输入: 它通常只需要 1 或 2 个 0 到 1 之间的随机数(比如 rand(), rand())。
  • 输出: 它“吐出”一个全新的、随机的向量 $\omega_m$(“这是我帮你‘摇’出来的方向!”)。

那么如果说完全随机地去得到方向,最后调用$D_{\omega}(\omega_m)$来事后告诉我们这个随机 $\omega_m$ 的概率是 $0.0001%$,这个渲染器是可以工作的,但是十分低效。

我们需要重要性采样,我们在得到方向地时候,这个随机方向的产生器就要产生更多的高概率方向,而较少地产生低概率方向

那么如何制作这样的采样器呢?有数学上的方法,但是有一个相对简单的几何途径可以做到。

几何方法(可见法线采样)

  1. 在代码中,假想一个由粗糙度 $\alpha_x, \alpha_y$ 定义的标准GGX椭球体
  2. 计算这个“椭球体”从方向 $\omega$ 看过去的2D剪影 (2D silhouette)
  3. 在这个2D剪影上,均匀地 (uniformly) 随机选择一个 2D 点 $(u, v)$
  4. 从这个 2D 点 $(u, v)$,沿着 $\omega$ 方向“逆向”投射回3D椭球体上,找到那个唯一的撞击点 $p$
  5. 获取这个 3D 撞击点 $p$ 在椭球体上的表面法线 $\omega_m$
  • 这个方法已经处理了遮蔽

dce143e9c6e7ddc7c17cd29ac5147019.png

微观“凸起”的 2D 剪影的形状,会根据你的“观察角度 $\omega$”而改变。这个算法必须精确地模拟这个改变

The Torrance–Sparrow Model

  1. 给定一个来自方向 $\omega$ 的观察光线,我们使用‘可见法线采样’(“骰子”),从**可见法线分布$D_\omega(\omega_m)$(“裁判”)所定义的概率中,生成 (sample) 一个微观法线 $\omega_m$**。并且,我们同时会使用$D_\omega(\omega_m)$ 这个公式,来计算出我们刚刚生成的那个 $\omega_m$ 所对应的概率密度值(一个数字/Float)

    • 这一步(在概念上)封装了“观察光线”与“随机的微观结构”求交 (intersecting) 的过程
    • 注意,这一步产生的$\omega_m$可能会相对于原宏观$\bold{n}$偏离很大,从而导致从一些角度射进来的光线,会被反射到V型谷的深处,而非去向摄像机,然后也许会经历多次反弹,最终射向摄像机。但是,Smith’s GGX的数学模型没有包括这种情形,导致了能量损失,其较为粗略地将这样的光线终止。当材质非常粗糙的时候,这种情况将会很常见。有一些研究进行了对这种情况的不同程度的修补。如 Fast-MSX: Fast Multiple Scattering ApproximationFast-MSX
  2. (光线)在被采样的微表面上的反射,是使用镜面反射定律 (law of specular reflection) 和菲涅尔方程 (Fresnel equations) 来建模的

    • 这会(反向)推导出一个入射方向 $\omega_i$,以及一个反射系数 (reflection coefficient),该系数会衰减 (attenuates) 这条路径所携带的光
  3. 最终,散射的光会(再)乘以一个 $G_1(\omega_i)$,以计入其他微表面所造成的阴影效应

在渲染器中,我们有来自上一次光的交互带来的$\omega_o$,我们要算出$\omega_i$ 以及其对应的概率密度,并完成采样。

想象一个法线 $\omega_m$。向量 $\omega_o$ 和 $\omega_i$ 在 $\omega_m$ 的两侧对称
注意:在 PBRT 的约定中,所有向量 $\omega_i, \omega_o, \omega_m$ 都指向外

b421a5c1874e4b392882cf7fa8a1fb7e.png

根据镜面反射关系,有:
$\omega_i = -\omega_o + 2(\omega_m \cdot \omega_o) \omega_m$

$\omega_m = \frac{\omega_i + \omega_o}{|\omega_i + \omega_o|}$

我们固定出射方向 $\omega_o$,并可视化一组微表面(用蓝色阴影表示),这些微表面能将来自入射方向(用绿色阴影表示)周围的一个弧/锥的光线有效反射出去

  • 在 (a) 所示的二维平面 (flatland) 设置中,这组有效的微表面 (admissible microfacets) 只是一个更小的弧。3D 情况则更复杂,涉及到球面圆锥截面 (spherical conic sections)。
  • 在 (b) 中,当 $\omega_i$ 锥体的中心与 $\omega_o$ 对齐时,有效的微表面形成一个球面圆形 (spherical circle)。
  • 在 (c) 中,当 $\omega_o$ 与 $\omega_i$ 锥体中心方向之间呈 $45^{\circ}$ 角时,有效的微表面形成一个球面椭圆 (spherical ellipse)
  • 在 (d) 中,当角度为 $90^{\circ}$ 时,结果则是一个球面双曲线 (spherical hyperbola)

我们想要知道最终选出的$\omega_i$的概率密度是多少,而非$\omega_m$,他们的关系是:

$p(\omega_i) d\omega_i = p(\omega_m) d\omega_m$
$p(\omega_i) = p(\omega_m) \times \frac{d\omega_m}{d\omega_i}$

我们先关注 $\frac{d\omega_m}{d\omega_i}$,其表示在 $\omega_m$ 空间中一个无限小的立体角 $d\omega_m$”,除以“在 $\omega_i$ 空间中对应的那个无限小的立体角 $d\omega_i$。

而又有,在球面坐标中,微分立体角 $d\omega = \sin\theta d\theta d\phi$

$$
\frac{d\omega_m}{d\omega_i} = \frac{\sin \theta_m d\theta_m d\phi_m}{\sin \theta_i d\theta_i d\phi_i}
$$

$$
\begin{aligned}
\frac{d\omega_m}{d\omega_i} &= \frac{\sin \theta_m d\theta_m d\phi_m}{\sin (2\theta_m) (2 d\theta_m) (d\phi_m)} \
&= \frac{\sin \theta_m}{ (2 \sin \theta_m \cos \theta_m) \cdot 2} \
&= \frac{1}{4 \cos \theta_m} \
&= \frac{1}{4(\omega_i \cdot \omega_m)} = \frac{1}{4(\omega_o \cdot \omega_m)}
\end{aligned} \quad
$$

也就是

$$
p(\omega_i) = D_\omega(\omega_m) \times \frac{1}{4(\omega_o \cdot \omega_m)}
$$

BSDF

evaluate 公式的推导:

回顾一下渲染方程:
$L_o(\text{p}, \omega_o) = \int_{H^2(\mathbf{n})} f_r(\text{p}, \omega_o, \omega_i) L_i(\text{p}, \omega_i) |\cos \theta_i| d\omega_i$
单样本蒙特卡洛估计器:

$$
\approx \frac{f_r(\text{p}, \omega_o, \omega_i) L_i(\text{p}, \omega_i) |\cos \theta_i|}{p(\omega_i)}
$$

结合在一起,有

$$
\begin{aligned}
L_o(\text{p}, \omega_o) &= \int_{H^2(\mathbf{n})} f_r(\text{p}, \omega_o, \omega_i) L_i(\text{p}, \omega_i) |\cos \theta_i| d\omega_i \
&\approx \frac{f_r(\text{p}, \omega_o, \omega_i) L_i(\text{p}, \omega_i) |\cos \theta_i|}{p(\omega_i)}
\end{aligned}
$$

Torrance–Sparrow 模型给出的单光线的辐射亮度,应该和蒙特卡洛估计相一致:

$$
\frac{f_r(\text{p}, \omega_o, \omega_i) L_i(\text{p}, \omega_i) |\cos \theta_i|}{p(\omega_i)} \stackrel{!}{=} F(\omega_o \cdot \omega_m \text{(反射角度)}) G_1(\omega_i) L_i(\text{p}, \omega_i)
$$

把两边的 $L_i$ 消掉,有

$$
\frac{f_r \cdot |\cos \theta_i|}{p(\omega_i)} = F(\omega_o \cdot \omega_m) G_1(\omega_i)
$$

移项,有

$$
f_r = \frac{F \cdot G_1(\omega_i) \cdot p(\omega_i)}{|\cos \theta_i|}
$$

代入 $p(\omega_i)$,有

$$
p(\omega_i) = p(\omega_m) \times \frac{d\omega_m}{d\omega_i}
$$

而$p(\omega_m)$是:

$$
p(\omega_m) = D_\omega(\omega_m) = \frac{G_1(\omega_o) D(\omega_m) (\omega_o \cdot \omega_m)}{(\mathbf{n} \cdot \omega_o)}
$$

$\frac{d\omega_m}{d\omega_i}$ 是:
$\frac{d\omega_m}{d\omega_i} = \frac{1}{4 (\omega_o \cdot \omega_m)}$
则有:

$$
p(\omega_i) = \frac{G_1(\omega_o) D(\omega_m)}{4 (\mathbf{n} \cdot \omega_o)}
$$

则有:

$$
f_r = \frac{F(\omega_o \cdot \omega_m) \cdot G_1(\omega_i) \cdot p(\omega_i)}{(\mathbf{n} \cdot \omega_i)}
$$

则有:

$$
f_r = \frac{F(\omega_o \cdot \omega_m) \cdot G_1(\omega_i) \cdot \left[ \frac{G_1(\omega_o) D(\omega_m)}{4 (\mathbf{n} \cdot \omega_o)} \right]}{(\mathbf{n} \cdot \omega_i)}
$$

然而,这个 $G = G_1 \times G_1$ 的“非相关”几何项会导致渲染过暗。因此,在最终的实现中,我们将其替换为物理上更精确的“高度相关”模型 $G_2 = \frac{1}{1 + \Lambda(\omega_i) + \Lambda(\omega_o)}$

Parallel Algorithm

参考
3-Parallel-Algorithms.pptx

Parallel Reduction

Arithmetic intensity: compute to memory access ratio 进行一次内存访问时,可以执行多少次计算操作。这个比值越大,往往意味着性能更好。

找到数组的和,最大值,连续积,均值

Reduction:  An operation that computes a single result from a set of data

在并行计算中,为了高效地对大量数据进行规约操作(如求和、求最大值等),算法通常采用迭代的形式。在每一轮迭代中,活动的线程会将两两配对的元素进行运算,从而将数据规模减半(因为每次迭代数据都会减半,往往是$\log_2{n}$次迭代)

数组的和

561da265f725d8991356664ba07b065c.png

分治分冶,两个两个地求和下去
$O(logn)$ for n elements

8966cec0f82d97fc5b08cee6d65d357a.png

第一行的$\log_2{n} -1$ 指的是总共的迭代次数 - 1,第一行的for是用来遍历迭代的,di表示是低几次迭代

第二行的all意味着并行,k是线程的索引, 步长为$2^{d+1}$,与迭代次数有关。例如, 若若当前迭代为第0次,那么步长为$2$,如果是第1次迭代,步长为$4$,如果是第2次迭代,步长为$8$

第三行执行了加法的具体内容。

当第0次迭代时(d=0)

1
2
for all k = 0 to n -1 by 2 in parallel
x[k + 1] += x[k]

即,
线程0,执行x[0 + 1] += x[0],也就是 x[1] += x[0]

线程2,执行x[2 + 1] += x[2],也就是 x[3] += x[2]

线程4,执行x[4 + 1] += x[4],也就是 x[5] += x[4]

以此类推…

当第1次迭代时(d=1)

1
2
for all k = 0 to n -1 by 4 in parallel
x[k + 3] += x[k + 1]

即,
线程0,执行x[0 + 3] += x[0 + 1],也就是 x[3] += x[1]

线程4,执行x[4 + 3] += x[4 + 1],也就是x[7] += x[5]

Scan

All-Prefix-Sums 前缀和(前缀规约)

5e0b82b79cd53390e40890174ec91776.png

这里的⊕指的是二元运算,其需要两个操作数,且满足结合律运算,即(a⊕b)⊕c=a⊕(b⊕c)

$I$ 代表身份元,它是指与任何元素进行二元运算后,结果仍然是该元素的特殊值。

  • 对于加法 (+),身份元是 0,因为 a+0=a
  • 对于乘法 (×),身份元是 1,因为 a×1=a
  • 对于最大值 (max),身份元是 −∞ (负无穷大),因为 max(a,−∞)=a

结合律对设计并行算法至关重要,因为它允许我们以任何顺序对数据进行分组和处理,而不会影响最终结果。

  • Exclusive Scan
    不包括当前指向的数组中的元素
    In: [ 3 1 7 0 4 1 6 3]
    Out: [ 0 3 4 11 11 15 16 22]
  • Inclusive Scan(Prescan) 闭扫描
    包括当前指向的数组中的元素
    In: [ 3 1 7 0 4 1 6 3]
    Out: [ 3 4 11 11 15 16 22 25]

如何从一个闭扫描创建开扫描(逆向扫描)?

Input: [ 3 4 11 11 15 16 22 25]
将每一位右移,在首位补Identity(对于这里是0),有
[ 0 3 4 11 11 15 16 22]
Exclusive: [ 0 3 4 11 11 15 16 22]

如何从一个开扫描创建闭扫描(逆向扫描) Prescan?

我们既需要开扫描,也需要原始的输入

$$
Inclusive[i]=Exclusive[i]+Input[i]
$$

将开扫描与原始输入的元素逐个相加,
得到最终结果 [ 3, 4, 11, 11, 15, 16, 22, 25]

A parallel algorithm for Inclusive Scan

对于单线程来说,这个算法很简单。首位照抄原始输入的首位,之后的元素从前一个元素加上原始输入当前的元素即可。

1
2
3
out[0] = in[0]; // assuming n > 0
for (int k = 1; k < n; ++k)
out[k] = out[k – 1] + in[k];

若如此做,对于n个元素的数组,我们需要执行 n-1 次加法操作。

并行算法的时间复杂度是$O(logn)$,因为其不再必须等待上一步。
一般的并行化算法(Hillis-Steele)可以将工作总量变为$O(nlogn)$ ,而更高效的算法可以将工作总量优化到 $O(n)$

Native Parallel Scan

1
2
3
4
5
for d = 0 to (log₂n) - 1
for all k in parallel
if(k >= 2^(d)){
x[k] = x[k - 2^d] + x[k];
}

这里的d是指当前处理的是第几次迭代
注意,这里的k步长总是1, 但是其会经过$k >= 2^{d}$ 这个判断来去除一些不需要进行的运算。

由于我们无法控制线程的先后执行顺序,且算法中会出现依靠数组中其他值的情况,例如计算 Array[1] = Array[1] + Array[0],数组中的元素可能因为被其他值更改而污染。所以在实现中使用乒乓缓存。即,在一个迭代中,只从一个数组中读,只写入另一个数组,然后交换这两个数组。

d = 0 .png

d = 1.png

d = 2.png

269489db8ec37df325e5c34998029c3b.png

Work-Efficient Parallel Scan

若如此做,将会首先得到一个开扫描,最后进行一次逆向扫描,得到闭扫描

二叉树

利用一棵平衡二叉树来分两阶段执行Scan

假设输入是[0 1 2 3 4 5 6 7]

  • Up-Sweep
1
2
3
4
5
// Same code as our Parallel Reduction
// 注意,这里的to是一个闭区间,包含log_2{n} - 1
for d = 0 to log_2{n} - 1
for all k = 0 to n – 1 by 2^(d+1) in parallel
x[k + 2^(d+1) – 1] += x[k + 2^(d) – 1];

% 是取模运算,a % b 的结果是 a 除以 b 后的余数。 6 % 2 = 0

需要指出的是,由于步长的存在,这里伪代码的k是一个稀疏的、跳跃式增长的变量,用来标记每个计算单元的起始位置,是从“外部调度者”的视角来看待的。而CUDA中的k,或者线程id是每个线程自己的视角,那么经过了((k + 1) % fullStride != 0) 这样的判断之后,线程id已经成为了实际写入数据的位置。

也就是说,在kernel函数中,对于每个线程而言,

1
2
3
4
5
6
7
8
9
10
11
12
13
__global__ void kernParallelScan(int paddedN, int n, int d, int* idata) {
int k = blockIdx.x * blockDim.x + threadIdx.x;

int halfStride = 1 << (d);
int fullStride = halfStride * 2;

if (k >= n || ((k + 1) % fullStride != 0)) {
return;
}

// up-sweep
idata[k] += idata[k - halfStride];
}
  1. Imagine array as a tree
    Array stores only left child
    Right child is the element itself
  2. For node at index n
    Left child index = n/2 (rounds down)
    Right child index = n
    3aff2d8863d4de00dba4e0c48b049810.png

我们把这个树扫(推)上去,得到了上扫完成后的一个中间状态[0 1 2 6 4 9 6 28]

  • Down-Sweep
    “下扫”的核心思想是:从树的根节点开始,将每个节点所代表的“前缀和”分配给它的子节点,直到最底层的叶子节点(也就是我们的目标数组)
  1. 树顶的值换成0
  2. 从上至下地,一层层地,父节点把自己的值传给左孩子,把“自己的值 + 左孩子的原始值”传给右孩子

840a12cb01e26ea2a5b83eda8a123dc3.png

在得到我们上扫的结果后:
3aff2d8863d4de00dba4e0c48b049810.png
我们执行下列步骤:

  1. 根节点的值被设置为了0(注意,这一步应该只执行一次,而不是并行时,每个线程都这样设置一下,因为对于非第一个执行的线程,其根节点已经被第一个执行的线程更新过了)

  2. 蓝色层的6,拷贝父节点的值,新值变为了0;蓝色层的22,拷贝父节点的值,加上左边兄弟的原值6,新值变为6。 也就是蓝色这一层变为了[6, 22]

  3. 绿色这一层进行运算。
    绿色1,拷贝父节点的值,新值为0, 绿色5,拷贝父节点的值,加上左兄弟原值,变为1

绿色9,拷贝父节点的值6,新值为6,绿色13,拷贝父节点 6,再加上左边兄弟(绿色9)的原始值 9。新值为 6 + 9 = 15。
它们的值变成了 [0, 1, 6, 15]

  1. 黄色这一层进行运算,有新的黄色层[0 0 1 3 6 10 15 21],即开扫描

  2. 将开扫描加上原始输入,有闭扫描

  • Up-Sweep
    $O(n)$ adds
  • Down-Sweep
    $O(n)$ adds
    $O(n)$ swaps

减少加法操作次数?

Stream Compaction

类似于std::copy_if

在GPU的时候我们就把不要的数据给丢了,这样最终需要从GPU传到CPU的
数据量就少了很多。

Why scan?

  • Preserve the order
  • meet certain criteria

Step 1: Compute temporary array containing

  • 1 if corresponding element meets criteria
  • 0 if element does not meet criteria

Step 2 Run exclusive
scan on temporary array

405e86d49f951f8c0a855872626eb84a.png
这里第一行是原始数据,绿色表示满足了条件,红色表示未满足条件

黄色是临时数组

蓝色是临时数组的开扫描

Step 3 Scatter

  • Results of scan is index into final array
  • Only write an element if temporary array has a 1

9bff20d4a140f563607677ba49b2386f.png

那么利用这个开扫描,我们可以知道要开一个多大的数组,也就是最后一位值,且知道每一个要写的元素将会写的index

Summed Area Table (SAT)

7d4089d57ce4ed6743d3c664a1e36174.png

60518cac3995ce8c2161da26f4e7fbad.png
2D table where each element stores the sum of all elements in an input image between the lower left corner and the entry location.

SAT的本质是一个二维的包含式前缀和。

通过一次预计算,使得我们可以在“固定的时间”内,极速计算出图像中任意矩形区域内所有像素值的总和(以及平均值)

它的最大优点是,可以用 固定的时间O(1) 对图像的每个像素应用任意尺寸的矩形滤镜。即计算一个 3x3 小方框内所有像素的平均值,和计算一个 500x500 巨大方框内像素的平均值,花费的时间是完全相同的。

我们不需要遍历矩形内的每一个像素。我们只需要从一个预先计算好的“和区域表(SAT)”中,读取四个角点的值即可。

$$
s_{filter} = \frac{s_ur - s_ul - s_lr + s_ll}{w \times h}
$$

SAT(D) - SAT(B) - SAT(C) + SAT(A)

以近似景深为例
1.对于屏幕上的每一个像素,我们读取它的深度值。
2. 根据深度值和预设的焦距,计算出这个像素应该有多“糊”。这个模糊程度可以直接转换成一个矩形模糊窗口的尺寸(比如5x5, 15x15等)
3. 利用SAT,我们只需4次查询,就能立刻得到这个动态尺寸的矩形窗口内的像素平均值。
5. 将这个平均值作为该像素的最终颜色

GPU Implementation Inclusive Scan

  1. 原始输入
1
2
3
[ 1, 2, 3 ]
[ 4, 5, 6 ]
[ 7, 8, 9 ]
  1. 水平扫描
    我们对图像的每一行,独立地执行一次包含式扫描
    第1行: [1, 1+2, 1+2+3] ➔ [ 1, 3, 6 ]

第2行: [4, 4+5, 4+5+6] ➔ [ 4, 9, 15 ]

第3行: [7, 7+8, 7+8+9] ➔ [ 7, 15, 24 ]

1
2
3
[ 1,  3,  6 ]
[ 4, 9, 15 ]
[ 7, 15, 24 ]
  1. 垂直扫描
    我们对上一步得到的中间结果的每一列,再次独立地执行一次包含式扫描

第1列: [1, 1+4, 1+4+7] ➔ [ 1, 5, 12 ]

第2列: [3, 3+9, 3+9+15] ➔ [ 3, 12, 27 ]

第3列: [6, 6+15, 6+15+24] ➔ [ 6, 21, 45 ]

1
2
3
4
// 最终的 SAT
[ 1, 3, 6 ]
[ 5, 12, 21 ]
[ 12, 27, 45 ]

Radix Sort

k-bit keys require k passes k比特的键需要k轮处理

如果要对一个k比特的整数进行排序,算法需要进行k轮
8f8ee73e4e7e6936fadc46e996d64378.png

  1. (Pass 1)根据所有数字的最低比特位 least significant bit (LSB)(第0位是0还是1)进行分组排序
    1669d7b3a0a36566d2c13084d40b4ea1.png

  2. (Pass 2)在上一轮结果的基础上,根据所有数字的次低比特位(第1位)进行分组排序
    43a6170210b79a9a010842f85c1e8214.png

  3. ….

  4. (Pass k)根据最高比特位most significant bit (MSB)
    a6d01964c23397cf74891f75df8a330f.png

Parallel ?

  1. Break input arrays into tiles

    • Each tile fits into shared memory for an SM
  2. Sort tiles in parallel with radix sort

    • Each pass is done in sequence after the previous pass. No parallelism
    • Can we parallelize an individual pass? How?
  3. Merge pairs of tiles using a parallel bitonic merge until all tiles are merged.

Parallel in a pass

4c0652db94abe23cc315226af5fb20ee.png

  1. 计算 e 数组
    b4837ff33ec2cf098a2926555393c367.png
  2. 对 e 数组执行开扫描
    7cb32f072034dc8e7717e7734e685891.png
  3. 计算 totalFalses
1
2
3
totalFalses = e[n – 1] + f[n – 1]
totalFalses = 1 + 3
totalFalses = 4
  1. Compute t array
  • t array is address for writing true keys
1
2
totalFalses = 4
t[i] = i – f[i] + totalFalses

831d7f4296dee13007a2d1c491ecbd76.png

  1. Scatter based on address d
1
2
d[i] = b[i] ? t[i] : f[i] 

98727e49256db07bf9d40d5fbbbdfb33.png

beeac03d3c61f689b1d4861a233b0b97.png

算法将相邻的两个有序数据块(Tile 0 和 Tile 1,Tile 2 和 Tile 3,以此类推)两两配对。

GPU上的不同线程组会同时对这些数据块对执行并行的归并操作

Scan Revisited

  • Limitations
    • Requires power-of-two length
    • Executes in one block (unless only using global memory)
      • Length up to twice the number of threads in a block
  1. Devide the array into blocks
  2. Run scan on each block
    e46df25bd82c53fabc6e6ee54b262985.png
  3. Write total sum of each block into a new array
    8dd0cf3a8153fcb453c2267cb0856ae0.png
  4. Exclusive scan block sums to compute block increments
    4ad67ed60e1653b6e453e57114ccabc5.png
  5. Add block increments to each element in the corresponding block
    d5b9ca2632be12276dffddcc57541f0a.png

Scheduling

参考
2-CUDA-Introduction-2-of-2
Cuda 编程之 Tiling

Physical Architecture

NVIDIA GeForce 8800 GTX G80-300-A2

df3024cc4f9c45d0138a0b8f4d569ae1.png

d1d005177d7b9a00c9d316afc9e4c0e7.png

8ba3a2b1ab45dd6bb6914b2946debaa7.png

最小的单位, SP(Streaming Processor),然后是SPs(Streaming Processors)

以 Tesla 架构的 GeForce 8 系列搭载 G80 的 8800 GTX 为例,其拥有 16 个 Streaming Multiprocessor

Config Name Num
Shading Units 128
TMUs 32
ROPs 24
SM Count 16
L2 Cache 96KB

相比较之下,Ada LoveLace 架构的 4070 Super 其拥有56个Streaming Multiprocessor

需要注意的是,每个SM所能含有的最大threads 是随着架构的提升而增大的,不过至今的数代都保持在了2048这个数字。

Warp

Users don’t control warp, it is hardware group of 32 consecutive “lanes”(32 threads of a block)

This is why we keep threads-per-block size as multiple of 32

  • Each thread occupies 1 lane, warp lanes are consecutive threadIdx values

  • Unit of sheduling and execution

  • CUDA GPUs schedule warps for execution, not threads

  • All threads in a warp execute same instruction at the same clock-time

  • Zero-overhead context switching - All resources reside on the same SM until execution

  • A block will always on the same SM

  • All threads in a warp execute the same insturction

    • While all threads in a grid run the same kernel code, only threads within the same warp are guaranteed to execute the same instruction at the same time.
    • They are all working from the same set of instructions, but they are not synchronized. They run independently and can be at completely different points in the program at any time.
  • Swapping sheduled warps has zero overheads

  • Scheduler always tries to optimize execution

  • More resources required - less warp

    • Registers: A large but limited set of super-fast memory for each thread’s private variables.

    • Shared Memory: A fixed amount of fast on-chip memory allocated to thread blocks

  • One SM can handle multiple block at the same time

    • Warp Size is the unit of execution.
    • Concurrent Threads is the unit of residency. This is the total number of threads that are loaded, active, and have their resources allocated on the SM, ready to be executed.
Scenario Registers per Thread Max Concurrent Threads on SM Max Concurrent Warps on SM
High Usage 64 32,768 / 64 = 512 512 / 32 = 16
Low Usage 16 32,768 / 16 = 2048 2048 / 32 = 64

Example Ⅰ

32 threads per wap but 8 SPs per SM, What gives?

When an SM shedule its

A kernal has

  • 1 global memory read
  • 4 non-dependent multiples/adds

How many warps are required to hide the memory latency ?

Each warp has 4 multiples/adds
16 cycles

We need to cover 200 cycles

  • 200/16 = 12.5
  • ceil(12.5) = 13

13 warps are required

Example Ⅱ

  • What actually happens when you launch a kernel say with 100 blocks each with 64 threads?
    • Let’s say on a GT80 (i.e. 16 SMs, 8 SPs each)
    • Max T/block is 512, Max T/SM is 768 threads
    • Chip level scheduling:
      • 100 blocks of 64 threads
      • 768/64 => max 12 blocks can be scheduled for every SM
    • SM level scheduling:
      • 12 blocks = 768 threads = 24 warps
      • Warp scheduler kicks in:
        • 8 threads -> 8 threads -> 8 threads -> 8 threads(因为这个是8 SPs each)

上述描绘了无需考虑thread所使用的resources的简化情况, 注意,每个SM这里的确可以支持12个blocks,但是100个并不是说就使用9个SM,Since your 100 blocks are fewer than the GPU’s capacity of 192, the scheduler will load all 100 blocks onto the 16 SMs immediately. Some SMs will get 6 blocks, and some will get 7, until all 100 are distributed.

Divergence

What happens if branches in a warp diverge ?

ec87ad03a1ab1e1349f452dd47b3e862.png

If threads within a warp take different paths in an if-else statement, the hardware has to serialize the paths, executing one after the other, which can reduce performance.

Synchronization

  • Call __syncthreads();, it is on a block level

Why it is important that execution time be similar among threads ?
Long-running threads lead to inefficient stalls

Why does it only synchronize within a block?
Execution across blocks is not concurrent or ordered

Golden Role

For a __syncthreads() to work, every single thread in a block must be able to reach the same __syncthreads() call. If even one thread takes a path that skips the synchronization point, the other threads in the block will wait for it forever.

Deadlock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__global__ void faulty_kernel(float* data) {
int idx = threadIdx.x;

// Even and odd threads take different paths
if (idx % 2 == 0) {
// Even threads do some work...
data[idx] = 1.0f;

// ...and then wait here forever for the odd threads.
__syncthreads();
} else {
// Odd threads are paused while the 'if' block executes.
// They will never reach the __syncthreads() call above.
data[idx] = 2.0f;
}
}
  1. A warp encounters an if-else statement. Some threads evaluate the condition as true, and others evaluate it as false.
  2. The hardware picks one path to execute first (e.g., the if block). The threads that chose this path become active, while the threads that need to take the else path are temporarily paused (“masked off”).
  3. The active threads enter the if block and hit the __syncthreads(). They now stop and wait for all other threads in their block to also arrive at this exact synchronization point.
  4. Deadlock: The paused threads can never reach the __syncthreads() inside the if block because they are waiting to execute the else block. The active threads will never proceed because they are waiting for the paused threads. The entire block is now stuck in a permanent standoff.

Each __syncthreads is unique

Revisit of Matrix Multiply

The previous code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__global__ void MatrixMultiplyKernel(const float* devM, const float* devN,
float* devP, const int width)
{
int tx = threadIdx.x;
int ty = threadIdx.y;

// Initialize accumulator to 0
float pValue = 0;

// Multiply and add
for (int k = 0; k < width; k++) {
float m = devM[ty * width + k];
float n = devN[k * width + tx];
pValue += m * n;
}

// Write value to device memory - each thread has unique index to write to
devP[ty * width + tx] = pValue;
}

原来的想法很朴素,我最终的矩阵多大,我就开多少个线程去算,一个线程对应最终矩阵C的一个值。
d3c19410256d0c0e6f36c0828fbc1d8b.png
那么这个线程去算的时候呢,要看一下矩阵A的一行,再看矩阵B的一列。最终进行乘法、相加运算。C中的每个元素的线程都要去看A和看B,那么就会带来大量的对于Global Memory的访问,这会十分低效。

直觉上打一个不恰当的比方,我们给一个大房间铺地砖的时候,应该是拿个小推车去从大货车上装个一批,然后在房间中从这个小推车上拿了地砖去铺,小推车拿空了小推车再去装一批。而不是每铺一个地砖都要去大货车中拿一块地砖。

我们要做的是,找的一种办法提升shared memory的使用,减少Global Memory的访问,这种办法可以记录某些中间状态,让矩阵C的每个元素计算不至于总是去查A和、B
9e337856e7560f777e39050b03b69bfd.png
cb97f272024107617519bdf095999bf9.png
图片出自Cuda 编程之 Tiling

Say, we have matrix $A$

$$
\left[ \begin{array}{cc}
2 & 6 & 7 &5 \
3 & 1 & 4 &6 \
8 & 9 & 0 &1 \
2 & 7 & 7 &4
\end{array}
\right]
$$

matrix $B$

$$
\left[ \begin{array}{cc}
1 & 6 & 2 &1 \
3 & 9 & 8 &4 \
5 & 6 & 3 &9 \
1 & 0 & 7 &2
\end{array}
\right]
$$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
__global__ void MatrixMultiplyKernel(const float* devM, const float* devN,
float* devP, const int width)
{
__shared__ float sM[TILE_WIDTH][TILE_WIDTH];
__shared__ float sN[TILE_WIDTH][TILE_WIDTH];

int bx = blockIdx.x; int by = blockIdx.y;
int tx = threadIdx.x; int ty = threadIdx.y;

int col = bx * width + tx;
int row = by * width + ty;

// Initialize accumulator to 0
float pValue = 0;

// Multiply and add
for (int m = 0; m < width / TILE_WIDTH; m++) {
sM[ty][tx] = devM[row * width + (m * TILE_WIDTH + tx)];
sN[ty][tx] = devN[col + (m * TILE_WIDTH + ty) * Width];
__syncthreads();

for (int k = 0; k < TILE_WIDTH; ++k)
pValue += sM[ty][k] * sN[k][tx];
__syncthreads();
}
devP[row * width + col] = pValue;
}

for the first iteration, we have

Phase 1: Processing the First Pair of Tiles

Load Tiles into Shared Memory

matrix $sM$

$$
\left[ \begin{array}{cc}
2 & 6 \
3 & 1\
\end{array}
\right]
$$

matrix $sN$

$$
\left[ \begin{array}{cc}
1 & 6 \
3 & 9\
\end{array}
\right]
$$

__syncthreads();

Compute from Shared Memory

Our target thread (0,0) now calculates its part of the dot product using the values in the fast shared memory.

1
2
3
pValue += sM[0][0] * sN[0][0] + sM[0][1] * sN[1][0]
pValue += (2 * 1) + (6 * 3)
pValue = 20

Our target thread (0,1):

1
2
3
pValue += sM[0][0] * sN[0][1] + sM[0][1] * sN[1][1]
pValue += (2 * 6) + (6 * 9)
pValue = 66

Our target thread (1,0):

1
2
3
pValue += sM[1][0] * sN[0][0] + sM[1][1] * sN[1][0]
pValue += (3 * 1) + (1 * 3)
pValue = 6

Our target thread(1,1):

1
2
3
pValue += sM[1][0] * sN[0][1] + sM[1][1] * sN[1][1]
pValue += (3 * 6) + (1 * 9)
pValue = 27

__syncthreads();

all four of those calculations are done at the same time, in parallel.

Phase 2: Processing the Second Pair of Tiles

Load Tiles into Shared Memory

matrix $sM$

$$
\left[ \begin{array}{cc}
7 & 5 \
4 & 6\
\end{array}
\right]
$$

matrix $sN$

$$
\left[ \begin{array}{cc}
5 & 6 \
1 & 0\
\end{array}
\right]
$$

__syncthreads();

Compute from Shared Memory

Our target thread (0,0) now calculates its part of the dot product using the values in the fast shared memory.

1
2
3
4
5
pValue += sM[0][0] * sN[0][0] + sM[0][1] * sN[1][0]
pValue += (7 * 5) + (5 * 1)
pValue += 40

pValue = 60

Our target thread (0,1):

1
2
3
4
5
pValue += sM[0][0] * sN[0][1] + sM[0][1] * sN[1][1]
pValue += (7 * 6) + (5 * 0)
pValue += 42

pValue = 108

Our target thread (1,0):

1
2
3
4
5
pValue += sM[1][0] * sN[0][0] + sM[1][1] * sN[1][0]
pValue += (4 * 5) + (6 * 1)
pValue += 26

pValue = 32

Our target thread(1,1):

1
2
3
4
5
pValue += sM[1][0] * sN[0][1] + sM[1][1] * sN[1][1]
pValue += (4 * 6) + (6 * 0)
pValue += 24

pValue = 51

__syncthreads();

all four of those calculations are done at the same time, in parallel.

Now, we have the left top block of C:

$$
\left[ \begin{array}{cc}
60 & 108 \
32 & 51\
\end{array}
\right]
$$

为什么我们可以这么做?

Dot product can be done as partial nums

在naive中,我们想求一个C的元素,我们一次性去拿A的对应横行和B的对应纵行进行计算。在tiling中,我们把这个计算分为多步,对于每个我们关注的C的子矩阵,即一个block,比如例子这里就是left top block of C,我们去看原矩阵A、B对应的block,而后进行运算。这会让我们不再每求一个C的元素都要将A的对应横行和B的对应纵行全部访问一遍,而是,我关注的这个C的block在原A矩阵中的对应(横向)block 以及原B矩阵中的对应(竖向)的block块A,B,C, 做累加操作。每次我都会先缓存以下当前关注的A、B中的block,称作sM、sN
以下是block同步开始的
top left block of C
drawio
drawio

top second left block of C
drawio
drawio

Cpp & Basic Concepts

CPU side serial code controls(send commands to) CPU side parallel code

  • 初始化数据(在CPU内存中)
  • 分配GPU内存
  • 将数据从CPU内存拷贝到GPU内存
  • 启动GPU上的核函数(Kernal)
  • 等待GPU计算完成
  • 将计算结果从GPU内存拷贝到CPU内存
  • 释放GPU和CPU内存

CUDA 函数执行空间限定符

限定符 执行位置 调用位置
__global__ 设备(GPU) 主机(CPU)
__device__ 设备 设备
__host__ 主机 主机

kernals are running on the GPU, so we use pointers to access memory

__global__

1
__global__ void myKernel()
  • must return void
  • 如果需要返回结果,必须通过传入指针,让核函数将结果写入GPU内存中
  • 使用一种特殊的 <<<...>>> 执行配置语法来调用,例如 myKernal<<<grid, block>>>(args...);

__device__

1
__device__ float myDeviceFunction()
  • 这是一个只能在GPU上执行,并且也只能被其他 __global____device__ 函数调用的函数。它通常用于在核函数中实现一些可重用的辅助功能,类似于普通C++代码中的普通函数。
  • Inlined by default

for all device code

  • No static variables
    • lifetime of the program
    • on the gpu there is no lifetime concept
  • No malloc()
    • never
    • many of threads tring to allocate, not enough cache to do that
    • compiler allows, but performance issue
    • GPU内存最好由主机端统一管理。标准的做法是在主机端使用 cudaMalloc() 分配一大块内存,然后将指向这块内存的指针传递给核函数。GPU线程在这个预先分配好的内存区域中进行读写操作。

__host__

1
__host__ int myHostFunction()

这就是一个普通的C/C++函数,在CPU上执行,也只能被CPU调用。如果不写任何限定符,函数默认就是 __host__

组合用法

__device__ __host__ void func() VALID
这意味着这个函数被编译了两次:一个版本用于在CPU上调用,另一个版本用于在GPU上调用。这在我们希望CPU和GPU共享某个工具函数(例如一个简单的数学计算)时非常有用。

__global__ __host__ void func() INVALID
这个组合在逻辑上是矛盾的。__global__ 的核心定义是“从CPU调用,在GPU执行”,而 __host__ 的定义是“从CPU调用,在CPU执行”。一个函数不能同时满足这两种执行模式,因此编译器禁止这种组合。

if __global__, it is only __global__ nothing else

pow, sqrt, exp, sin, cos
__powf, __sinf, __logf, __exp

Grid, Block, Thread

这些都是抽象的概念,并非是真是的物理结构,区分这些概念是为了更高效地编程
b789eb919c1cff5db1c02942466e4fc9.png

让不同的线程,拿不同的数据,进行相同的运算

gridDimblockDim 都是dim3

1688f01b2eea083b6cd69c7e1d158aed.png

上图中的, 表示1D Grid, 其在x方向上有4个Block,而其他方向上都只有1个Block (默认1个)

1
dim3 gridDim(4);

一般的,我们需要设置 block 中的每个维度的线程数为 32 的整数倍

Thread

执行计算的最基本单元。每个线程都会完整地执行一遍核函数 (__global__函数) 的代码。

Treading Model

Real world limitations
- No. of cores Cores/ Transistors for memory
- Power
- Scheduling

Thread Hierarchies

__global__ 函数内部,可以直接使用一些内置的只读变量来确定当前线程的位置:
dim3 gridDim 网格的维度
dim3 blockDim 线程块的维度
uint3 blockIdx 当前线程所在Block在Grid中的索引
uint3 threadIdx 当前线程在Block中的索引

1D: Thread ID == Thread Index
2D with size (Dx, Dy)
Thread ID of index (x, y) == x + y Dx
3D with size (Dx, Dy, Dz)
Thread ID of index (x, y, z) == x + y Dx + z Dx Dy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Kernel function to add two matrices
__global__ void matrixAddition(const float* A, const float* B, float* C, int width, int height)
{
// Calculate the global row and column index
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;

// Check if the thread is within the matrix bounds
if (col < width && row < height) {
int idx = row * width + col;
C[idx] = A[idx] + B[idx];
}
}

int main()
{
int width = 1024;
int height = 1024;
int numElements = width * height;
size_t size = numElements * sizeof(float);

// ... (此处省略了为 A, B, C 分配主机和设备内存,以及数据拷贝的代码) ...
// ... cudaMalloc, cudaMemcpy, etc. ...

// Define the dimensions of the thread block
// 通常选择一个二维的块,例如 16x16 或 32x32
dim3 threadsPerBlock(16, 16);

// Calculate the dimensions of the grid
// 向上取整,确保有足够的block覆盖整个矩阵
dim3 gridDim( (width + threadsPerBlock.x - 1) / threadsPerBlock.x,
(height + threadsPerBlock.y - 1) / threadsPerBlock.y );

std::cout << "Launching Kernel with Grid: (" << gridDim.x << ", " << gridDim.y << "), Block: (" << threadsPerBlock.x << ", " << threadsPerBlock.y << ")" << std::endl;

// Launch the kernel
matrixAddition<<<gridDim, threadsPerBlock>>>(A_d, B_d, C_d, width, height);

// ... (此处省略了将结果从C_d拷贝回C_h,以及释放内存的代码) ...
// ... cudaMemcpy, cudaFree, free ...

return 0;
}

Block

块内的线程可以相互协作,例如通过共享内存 (Shared Memory) 快速交换数据,也可以进行同步 (__syncthreads())。

一个块内的所有线程必须在同一个流式多处理器 (Streaming Multiprocessor, SM) 上执行

一个 Block 至多可以容纳 1024 个 Thread,这是自开普勒架构(GTX 10系列)显卡之后的规范。

但是,1024个线程并不意味着同一时间会执行1024个,最小的执行单位是Warp

1
dim3 threadsPerBlock(16, 16);

This line declares that each thread block will be a 2D grid containing 16 x 16 = 256 threads. You are creating a small, square team of threads. Here, threadsPerBlock.x is 16 and threadsPerBlock.y is 16. The z-dimension is 1 by default.

d812751cd8bd70d7974fb4061079c984.png

需要注意的是,这里的图片是Scalability的实例,并不是说每次SM就处理一个Block。在同一个Block上运行的threads 确实都在同一个SM上,也就意味着其都可以与L1通信,但是,通过这种办法来达成Block间的通信是不安全的。

不同块之间的线程是无法直接通信和同步的。

Grid

一组线程块的集合。
一个核函数的所有线程都组织在一个网格中

Curves & Spline

Refer to: The Continuity of Splines by Freya Holmér

  • Bernstein

image-20250526101818866

此处,$B_0^{3}$ 等亦可写作 $B_{0,3}(u) = C(3,0) \cdot u ^{0} \cdot (1-u)^{3}$

  • DeCasteljau

image-20250526102100357

image-20250526102115570

  • Polynomial Coefficients
    $$
    f(u) = b_0 + u(-3b_0 + 3b_1) + u^{2}(3b_0 -6b_1 + 3b_2) + u^{3}(-b_0 + 3b_1 -3b_2 + b_3)
    $$
    Efficient Evaluation using Horner’s Method:
    $$
    f(u) = C_0 + u(C_1 + u(C_2 + uC_3))
    $$

Subdivision of Bezier Curve

image-20250526102959749

Derivatives of Bezier Curves

For the case where u = 0

image-20250526104622928

when u = 1

Why Spline?

如果需要我们对一条曲线有更多的控制能力,我们可以考虑提高贝塞尔曲线的次数,例如,一个6次贝塞尔曲线会有7个控制点,其形如:

$$
X(s) = (1-s)^6 P_0 + 6s(1-s)^5 P_1 + 15s^2(1-s)^4 P_2 + 20s^3(1-s)^3 P_3 + 15s^4(1-s)^2 P_4 + 6s^5(1-s) P_5 + s^6 P_6
$$

但是,移动每个控制点的时候,整个曲线都会受到影响,计算也会变得更加复杂。

为了更稳定地计算并获得更好地局部控制线和直观性,我们会将多段较低阶地贝塞尔曲线连接起来。

三种切线

chrome\_vcPTjok4lE.gif

Continuity

$C^{n}$ continuous if $A^{(i)}(t_{end}) = B^{(i)}(t_{start})$ for $i$ in $ {0,…,n}$

image-20250526064321719

观察下图,可以看到速度并不是连续的,也就是不满足 $C^1$ 连续性。即使原 spline 在 joint 处的两个控制点形成的两个切线已经 aligned (方向相同,但是大小不同)。

chrome\_Lda92InYAp.gif

当控制点关于 joint 点 mirror 时, $C^1$连续。

chrome\_sTLPFaDnwu.gif

如何构造出 $C^1$ 呢?Mirror 意味着怎么样的关系呢?

image-20250606075158351

我们对一段贝塞尔曲线求导,有

$$
f’(u) = -3b_0 + 3b_1 + 2u(3b_0 -6b_1 + 3b_2) + 3u^2(-b_0 + 3b_1 -3b_2 + b_3)
$$

那么,当$u_0 = 1$时(第一段曲线的右端点)

$$
f’(1) = -3b_2 + 3b_3
$$

当$u_1 = 0$ (第二段曲线的左端点)

$$
f’(0) = -3b_3 + 3b_4
$$

当$f’(1) =f’(0)$时,有

$$
b_4 = 2b_3 - b_2
$$

也就是说,当 Mirror 时,$b_4$ 的位置已经被 $b_2$ 与 $b_3$ 完全决定,不再能被用户自由控制。

$C^2$ 连续性?

chrome\_Xx82K9kfO3.gif
加速度改变速度。

我们对上面提到的速度再求一次导,有

$$
f’’(u) = 6b_0 -12b_1 + 6b_2 + 6u(-b_0 + 3b_1 -3b_2 + b_3)
$$

那么,当$u_0 = 1$时(第一段曲线的右端点)

$$
f’’(1) = 6b_1 - 12b_2 +6 b_3
$$

当$u_1 = 0$ (第二段曲线的左端点)

$$
f’’(0) = 6b_3 -12b_4 + 6b_5
$$

当$f’’(1) =f’(0)$时,有

$$
b_5 = b_1 - 2 b_2 + 2b_4
$$

而$C^{1}$ =又保证了,$b_4$ 已经完全被$b_3$ 与$b_2$ 控制

$$
b_4 = 2b_3 - b_2
$$

所以在$C^{2}$下有

$$
b_5 = b_1 + 4(b_3 - b_2)
$$

也就是说,当 $C^{2}$ 时,$b_5$ 的位置已经被 $b_1$ $b_2$ 与 $b_3$ 完全决定,不再能被用户自由控制。
chrome\_WDuM7jIPbn.gif
对于多段贝塞尔曲线构成的spline,保证$C^2$时,很多控制点的位置都已经被完全决定,控制能力变弱了,并且, 微小的控制会导致后面剧烈的变化。

Geometric Continuity

$A(t)$ and $B(t)$ are
$G^n$ ccontinuous if a function $g(t)$ exists so that $A(t)$ and $B(g(t))$ are $C^{n}$ continuous

$G^{1}$

chrome\_5eyM5Uy5jo.gif
切线连续性通常用 G¹ 连续性 (Geometric Continuity, first-order) 来表示。为了让两条曲线在连接点处达到 G¹ 连续,必须满足以下两个条件:

G⁰ 位置连续性 (Positional Continuity): 两条曲线的端点必须在连接点处重合。也就是说,它们必须接触。

切线方向相同: 在连接点处,两条曲线的切线向量必须指向相同的方向(或者完全相反的方向,但在实际应用中通常要求方向一致)。这意味着两条曲线在该点的变化趋势是一致的。
值得注意的是,G¹ 连续性只要求切线的方向相同,而不要求切线向量的大小(即参数导数的大小)也相同。如果切线向量的大小和方向都相同,那么就达到了更高级别的 $C^1$ 参数连续性 (Parametric Continuity, first-order)。 $C^1$ 连续性是比 G¹ 更严格的条件

满足 $G^{1}$ 时,有

$$
b_4 = b_3 + (b_3 - b_2) \beta
$$

即,共线同向,但不要求大小相同。

chrome\_cdfzZ0lzIx.gif

在曲面上,我们可以注意到反光有条缝
chrome\_ujR8IalkRS.gif

chrome\_L4eUaUYzGF.gif

Curvature $k$

$$ \kappa = \frac{\begin{vmatrix} P'_{x} & P''_{x} \\\\ P'_{y} & P''_{y} \end{vmatrix}}{{\Vert \mathbf{P}' \Vert}^3} $$

chrome\_HRGJI65w0F.gif

仅仅$C^{0}$时,也可能是Curvature连续的,而$C^{1}$时,未必连续。

$G^{2}$ 连续性

$$
\mathbf{P}_5 = \mathbf{P}_3 + (\mathbf{P}_3 - \mathbf{P}_2)(2\beta_1 + \beta_1^2 + \beta_2/2) + (\mathbf{P}_1 - \mathbf{P}_2)\beta_1^2
$$

$\mathbf{P}_5$ 被上述控制点与参数共同决定。
chrome\_Bp9R9dMuIx.gif

$G^{2}$ 以及以上被称为A级面

183e17fbf7e48acc503b5437b1011e6d.png
*as long as the curves are regular

Hermite Curves

贝塞尔曲线利用四个控制点来构造,而Hermite曲线由两个端点和端点的速度。即

$$
P(0) = P_0 \\
P(1) = P_1 \\
P’(0) = \mathbf{v}_0 \\
P’(1) = \mathbf{v}_1
$$

通过上述关系求解

$$
P(t) = at^3 + bt^2 + ct + d
$$

得到

$$
\mathbf{P}(t) =
\begin{bmatrix}
1 & t & t^2 & t^3
\end{bmatrix}
\begin{bmatrix}
1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
-3 & -2 & 3 & -1 \\
2 & 1 & -2 & 1
\end{bmatrix}
\begin{bmatrix}
\mathbf{P}_0 \\
\mathbf{v}_0 \\
\mathbf{P}_1 \\
\mathbf{v}_1
\end{bmatrix}
$$

通过设置连接点处切向量相等,可以方便地实现$C^1$

Hermite to Bezier:

$$
B_0 = P_0 \\
B_1 = P_0 + v_0/3 \\
B_2 = P_1 - v_1/3 \\
B_3 = P_1
$$

Cardinal Spline

从此处开始$ \mathbf{P}_0$,$ \mathbf{P}_1$,$ \mathbf{P}_2$,$ \mathbf{P}_3$ 指的是spline上的相邻四个joints点

如何串联一系列给定的点呢?
我们可以采用一种类似Hermite的方法,我们计算一个joint相邻两点的位移,并以此作为此点的速度
chrome\_XaGsOtgDjK.gif

但首尾两点并没有被穿进去,我们可以将原第二个点关于首个joint对称过去。
178c1af2ef47b81bb218be9fb68c13f5.png

但是这样得到的曲线看起来有些呆板。出现了长段的较为平坦的线条,我们可以引入一个 Scale 参数 $s$ 来缩放我们的速度。当$s$接近于0的时候,也就退化成了线性spline

$$
\mathbf{P}(t) =
\begin{bmatrix}
1 & t & t^2 & t^3
\end{bmatrix}
\begin{bmatrix}
0 & 1 & 0 & 0 \\
-s & 0 & s & 0 \\
2s & s-3 & 3-2s & -s \\
-s & 2-s & s-2 & s
\end{bmatrix}
\begin{bmatrix}
\mathbf{P}_0 \\
\mathbf{P}_1 \\
\mathbf{P}_2 \\
\mathbf{P}_3
\end{bmatrix}
$$

当$s = 0.5$时,我们有

Catmull-Rom Spline

  1. Interpolating every point!
  2. $C^1$ continuous, not $C^{2}$
  3. No need to specify tangents

$$
\mathbf{P}(t) = \begin{bmatrix} 1 & t & t^2 & t^3 \end{bmatrix} \frac{1}{2} \begin{bmatrix} 0 & 2 & 0 & 0 \\ -1 & 0 & 1 & 0 \\ 2 & -5 & 4 & -1 \\ -1 & 3 & -3 & 1 \end{bmatrix} \begin{bmatrix} \mathbf{P}_0 \\ \mathbf{P}_1 \\ \mathbf{P}_2 \\ \mathbf{P}_3 \end{bmatrix}
$$

image-20250606150017612

B-Spline(Basis-Spline)

那么,有没有一种构造Spline的方式,使之可以满足$C^2$呢?也就是说,我们要对这个求解:

$$
\mathbf{P}(t) =
\begin{bmatrix}
1 & t & t^2 & t^3
\end{bmatrix}
\begin{bmatrix}
\textcolor{red}{c_0} & \textcolor{cyan}{c_1} & \textcolor{green}{c_2} & \textcolor{orange}{c_3} \\
\textcolor{red}{c_4} & \textcolor{cyan}{c_5} & \textcolor{green}{c_6} & \textcolor{orange}{c_7} \\
\textcolor{red}{c_8} & \textcolor{cyan}{c_9} & \textcolor{green}{c_{10}} & \textcolor{orange}{c_{11}} \\
\textcolor{red}{c_{12}} & \textcolor{cyan}{c_{13}} & \textcolor{green}{c_{14}} & \textcolor{orange}{c_{15}}
\end{bmatrix}
\begin{bmatrix}
\textcolor{red}{\mathbf{P}_0} \\
\textcolor{cyan}{\mathbf{P}_1} \\
\textcolor{green}{\mathbf{P}_2} \\
\textcolor{orange}{\mathbf{P}_3}
\end{bmatrix}
$$

$$
\begin{gather}
\textcolor{red}{B_0(1) = 0} \tag{1} \
\textcolor{red}{B’{0}(1) = 0} \tag{2} \
\textcolor{red}{B’’
{0}(1) = 0} \tag{3} \
\textcolor{red}{B_0(0)} = \textcolor{cyan}{B_1(1)} \tag{4} \
\textcolor{red}{B’{0}(0)} = \textcolor{cyan}{B’{1}(1)} \tag{5} \
\textcolor{red}{B’’{0}(0)} = \textcolor{cyan}{B’’{1}(1)} \tag{6} \
\textcolor{cyan}{B_1(0)} = \textcolor{green}{B_2(1)} \tag{7} \
\textcolor{cyan}{B’{1}(0)} = \textcolor{green}{B’{2}(1)} \tag{8} \
\textcolor{cyan}{B’’{1}(0)} = \textcolor{green}{B’’{2}(1)} \tag{9} \
\textcolor{green}{B_2(0)} = \textcolor{orange}{B_3(1)} \tag{10} \
\textcolor{green}{B’{2}(0)} = \textcolor{orange}{B’{3}(1)} \tag{11} \
\textcolor{green}{B’’{2}(0)} = \textcolor{orange}{B’’{3}(1)} \tag{12} \
\textcolor{orange}{B_3(0) = 0} \tag{13} \
\textcolor{orange}{B’{3}(0) = 0} \tag{14} \
\textcolor{orange}{B’’
{3}(0) = 0} \tag{15} \
\textcolor{red}{B_0(t)} + \textcolor{cyan}{B_1(t)} + \textcolor{green}{B_2(t)} + \textcolor{orange}{B_3(t)} = 1 \tag{16}
\end{gather}
$$

chrome\_s6bGCm2Kqa.gif

$$
\mathbf{P}(t) =
\begin{bmatrix}
1 & t & t^2 & t^3
\end{bmatrix}
\frac{1}{6}
\begin{bmatrix}
1 & 4 & 1 & 0 \
-3 & 0 & 3 & 0 \
3 & -6 & 3 & 0 \
-1 & 3 & -3 & 1
\end{bmatrix}
\begin{bmatrix}
\textcolor{red}{\mathbf{P}_0} \
\textcolor{cyan}{\mathbf{P}_1} \
\textcolor{green}{\mathbf{P}_2} \
\textcolor{orange}{\mathbf{P}_3}
\end{bmatrix}
$$

  • Not interpolating
  • $C^2$ continuous! $G ^ 2$

B 样条的递推定义

refer to B样条的定义de boor-cox公式的理解

数学定义:

$$
P(t) = \sum_{i=0}^{n} P_i F_{i,k}(t) \qquad t \in [t_{k-1}, t_{n+1}]
$$

  • $k$ 是 B 样条的次数

  • $n$ 是控制点的最大索引,如果有$P_{i}, (i = 0, 1, …, n)$共 $n + 1$个控制点,那么最大索引就是$n$

  • $i$ 是这些控制点的序号

de Boor-Cox 递推定义,只与 t 有关

基函数的递推:

$$
\left{
\begin{aligned}
& F_{i,0}(t) =
\begin{cases}
1, & t_i \le t < t_{i+1} \
0, & \text{其他}
\end{cases}
\[2ex]
& F_{i,k}(t) = \frac{t-t_i}{t_{i+k}-t_i} F_{i,k-1}(t) + \frac{t_{i+k+1}-t}{t_{i+k+1}-t_{i+1}} F_{i+1,k-1}(t)
\[2ex]
& \text{约定 } \frac{0}{0} = 0
\end{aligned}
\right.
$$

$i$ 是基函数的编号,每个$P_i$都有一个权重函数,也就是基函数。

$i$ 也是向量节点 $T$ 中节点的索引。基函数$F_{i,k}(t)$的具体形状,它在时间轴上的起点和终点,完全是由节点向量$T = [t_0, t_1, t_2, … ]$ 决定的。$i$ 告诉我们,基函数 $F_{i,k}(t)$ 在 $[t_i, t_{i+k+1}]$ 是不为零的。

控制点的递推:

$$
\begin{equation*}
\mathbf{P}{i,r}(t) = (1 - \alpha{i,r}) \mathbf{P}{i-1, r-1}(t) + \alpha{i,r} \mathbf{P}_{i, r-1}(t) \tag{}
\end{equation*}
$$

$$
\alpha_{i,r} = \frac{t - t_i}{t_{i+k+1-r} - t_i}
$$

$k$ 是 B 样条的次数,那么这个样条的阶数是$k+1$, 一段就有$k+1$个控制点

$r$ 是计算的轮数,$r$从1走到$k$

我们把所有的(不仅是某一段的), 共(n + 1)个控制点记作 $P_{i}, (i = 0, 1, …, n)$ ,有 n + k +2 ($节点数 = 总控制点数 + 次数 + 1$) 个节点矢量 $T = [t_0, t_1, … t_{n + k + 1}]$

例如,5 个控制点,3次的B样条曲线,由于一段是由4个控制点决定的,那么就有(5-3 = )2个线段,1个joint

对于零次B样条:

其基函数可以直接得到

6c9097579bdd211c010243a0d1b2a945.png

对于一次B样条:

$$
F_{i,1}(t) = \frac{t - t_i}{t_{i+1} - t_i} F_{i,0}(t) + \frac{t_{i+2} - t}{t_{i+2} - t_{i+1}} F_{i+1,0}(t)
$$

$$
F_{i+1,0}(t) =
\begin{cases}
1, & \text{若 } t_{i+1} \le t < t_{i+2} \[1.5ex]
0, & \text{其他}
\end{cases}
$$

image-20250606215923792

对于二次 B 样条:

$$
F_{i,2}(t) = \frac{t-t_i}{t_{i+2}-t_i} F_{i,1}(t) + \frac{t_{i+3}-t}{t_{i+3}-t_{i+1}} F_{i+1,1}(t)
$$

$$
F_{i+1,1}(t) =
\begin{cases}
\frac{t-t_{i+1}}{t_{i+2}-t_{i+1}}, & \text{若 } t_{i+1} \le t < t_{i+2} \[2ex]
\frac{t_{i+3}-t}{t_{i+3}-t_{i+2}}, & \text{若 } t_{i+2} \le t < t_{i+3} \[2ex]
0, & \text{其他}
\end{cases}
$$

image-20250606220329168

对于三次 B 样条:

$$
F_{i,3}(t) = \frac{t - t_i}{t_{i+3} - t_i} F_{i,2}(t) + \frac{t_{i+4} - t}{t_{i+4} - t_{i+1}} F_{i+1,2}(t)
$$

$$
F_{i+1,2}(t) =
\begin{cases}
\frac{t - t_{i+1}}{t_{i+3} - t_{i+1}} \cdot \frac{t - t_{i+1}}{t_{i+2} - t_{i+1}}, & \text{若 } t_{i+1} \le t < t_{i+2} \[3ex]
\frac{t-t_{i+1}}{t_{i+3}-t_{i+1}} \cdot \frac{t_{i+3}-t}{t_{i+3}-t_{i+2}} + \frac{t_{i+4}-t}{t_{i+4}-t_{i+2}} \cdot \frac{t-t_{i+2}}{t_{i+3}-t_{i+2}}, & \text{若 } t_{i+2} \le t < t_{i+3} \[3ex]
\frac{(t_{i+4}-t)^2}{(t_{i+4}-t_{i+2})(t_{i+4}-t_{i+3})}, & \text{若 } t_{i+3} \le t < t_{i+4} \[3ex]
0, & \text{其他}
\end{cases}
$$

image-20250606220640347

三次 B 样条, 控制点为$P_0 = (30, 0)$, $P_1 = (60, 10)$, $P_2 = (80, 30)$, $P_3 = (90, 60)$, $P_4 = (90, 90)$,节点向量(Knot Vector): $T = [t_0, t_1, t_2, t_3,t_4,t_5,t_6,t_7, t_8] = [0,0,0,0,0.5, 1,1,1,1]$
求曲线上一点 $P(\frac{1}{4})$ 的值

首先观察这个点在哪个区间上,由于$t_3 = 0 < \frac{1}{4} < t_4 = 0.5$, 那么这个区间的索引是 $i = 3$

在区间$[t_i, t_{i+1})$内, 对于$k$次B样条,需要用到$k + 1$个控制点,这些点的索引是$P_{i-k}$, $P_{i-k +1}, … P_{i}$

那么,在区间$[t_3, t_{4})$ 内,对于3次B样条,需要用到4个控制点,这些点的索引是$P_0, P_1, P_2, P_3$

对于 k 次B样条,需要经行 k 轮运算,每一轮都使用上一轮计算出的点进行插值。

回顾数学定义:

$$
P(t) = \sum_{i=0}^{n} P_i F_{i,k}(t)
$$

$$
P(\frac{1}{4}) = \sum_{i=0}^{3} P_i F_{i,3}(\frac{1}{4}) \
P(\frac{1}{4}) = P_0F_{0,3}(\frac{1}{4}) + P_1 F_{1,3}(\frac{1}{4}) + P_2 F_{2,3}(\frac{1}{4}) + P_3F_{3,3}(\frac{1}{4})
$$

据此,利用上述的关于$F_{i,k}$的递推关系,我们可以算出各个$F_{i,k}$ 的值,进而$P(\frac{1}{4})$的值。不过,基于$F_{i,k}$的递推关系,我们可以找到一些最终值和控制点的关系:

$$
\begin{equation*}
\mathbf{P}{i,r}(t) = (1 - \alpha{i,r}) \mathbf{P}{i-1, r-1}(t) + \alpha{i,r} \mathbf{P}_{i, r-1}(t) \tag{}
\end{equation*}
$$

$$
\alpha_{i,r} = \frac{t - t_i}{t_{i+k+1-r} - t_i}
$$

第一轮计算,也就是 $r = 1$:

计算 $P_{1,1}$ ,由 $P_{0,0}$ 和 $P_{1,0}$ 插值得到

  • 权重 $\alpha_{1,1} = \frac{t - t_1}{t_{1+3+1-1} - t_1} = \frac{0.25 - t_1}{t_4 - t_1} = \frac{0.25 - 0}{0.5 - 0} = 0.5$

  • $P_{1,1} = (1 - 0.5)P_{0,0} + 0.5 \cdot P_{1,0} = 0.5(30,0) + 0.5(60,10) = (15,0) + (30,5) = (45, 5)$

计算 $P_{2,1}$ (由 $P_{1,0}$ 和 $P_{2,0}$ 插值得到):

  • 权重 $\alpha_{2,1} = \frac{t - t_2}{t_{2+3+1-1} - t_2} = \frac{0.25 - t_2}{t_5 - t_2} = \frac{0.25 - 0}{1 - 0} = 0.25$
  • $P_{2,1} = (1 - 0.25)P_{1,0} + 0.25 \cdot P_{2,0} = 0.75(60,10) + 0.25(80,30) = (45, 7.5) + (20, 7.5) = (65, 15)$

计算 $P_{3,1}$ (由 $P_{2,0}$ 和 $P_{3,0}$ 插值得到):

  • 权重 $\alpha_{3,1} = \frac{t - t_3}{t_{3+3+1-1} - t_3} = \frac{0.25 - t_3}{t_6 - t_3} = \frac{0.25 - 0}{1 - 0} = 0.25$
  • $P_{3,1} = (1 - 0.25)P_{2,0} + 0.25 \cdot P_{3,0} = 0.75(80,30) + 0.25(90,60) = (60, 22.5) + (22.5, 15) = (82.5, 37.5)$

第二轮计算,也就是 $r = 2$:
计算 $P_{2,2}$ (由 $P_{1,1}$ 和 $P_{2,1}$ 插值得到):
权重 $\alpha_{2,2} = \frac{t - t_2}{t_{2+3+1-2} - t_2} = \frac{0.25 - t_2}{t_4 - t_2} = \frac{0.25 - 0}{0.5 - 0} = 0.5$
$P_{2,2} = (1 - 0.5)P_{1,1} + 0.5 \cdot P_{2,1} = 0.5(45,5) + 0.5(65,15) = (22.5, 2.5) + (32.5, 7.5) = (55, 10)$

计算 $P_{3,2}$ (由 $P_{2,1}$ 和 $P_{3,1}$ 插值得到):
权重 $\alpha_{3,2} = \frac{t - t_3}{t_{3+3+1-2} - t_3} = \frac{0.25 - t_3}{t_5 - t_3} = \frac{0.25 - 0}{1 - 0} = 0.25$
$P_{3,2} = (1 - 0.25)P_{2,1} + 0.25 \cdot P_{3,1} = 0.75(65,15) + 0.25(82.5, 37.5) = (48.75, 11.25) + (20.625, 9.375) = (69.375, 20.625)$

第三轮计算 ($r = 3$),最终结果
计算 $P_{3,3}$ (由 $P_{2,2}$ 和 $P_{3,2}$ 插值得到):
权重 $\alpha_{3,3} = \frac{t - t_3}{t_{3+3+1-3} - t_3} = \frac{0.25 - t_3}{t_4 - t_3} = \frac{0.25 - 0}{0.5 - 0} = 0.5$
$P_{3,3} = (1 - 0.5)P_{2,2} + 0.5 \cdot P_{3,2} = 0.5(55,10) + 0.5(69.375, 20.625)$
$P_{3,3} = (27.5, 5) + (34.6875, 10.3125) = (62.1875, 15.3125)$

Trajectory

$$
\begin{bmatrix} 1 & 0 & 0 & 0 \ 0 & 1! & 0 & 0 \ 0 & 0 & 1/2! & 0 \ 0 & 0 & 0 & 1/3! \end{bmatrix} \begin{bmatrix} \mathbf{p}_0 \ \mathbf{v}_0 \ \mathbf{g}_0 \ \mathbf{j}_0 \end{bmatrix}
$$

$$
\mathbf{P}(t) = \begin{bmatrix} 1 & t & t^2 & t^3 \end{bmatrix} \begin{bmatrix} \mathbf{p}_0 \ \mathbf{v}_0 \ \frac{1}{2}\mathbf{g}_0 \ \frac{1}{6}\mathbf{j}_0 \end{bmatrix} = \mathbf{p}_0 \cdot 1 + \mathbf{v}_0 \cdot t + \left(\frac{1}{2}\mathbf{g}_0\right) \cdot t^2 + \left(\frac{1}{6}\mathbf{j}_0\right) \cdot t^3
$$

Conclusion

chrome\_LUajWPqZNo.gif

Deg. Cont. Tangents Interpol. Use cases
Bézier 3 C⁰/C¹ manual some shapes, fonts & vector graphics
Hermite 3 C⁰/C¹ explicit all animation, physics sim & interpolation
Catmull-Rom 3 auto all animation & path smoothing
B-Spline 3 auto none curvature-sensitive shapes & animations, such as camera paths
Linear 1 C⁰ auto all dense data & interpolation where smoothness doesn’t matter

Handling Non-Uniform Time in Splines

目前讨论的是最经典的情况,即每个曲线段的“时长”都是1个单位(称之为**参数化均匀 (Uniform Parameterization)**)。

当每个曲线段的实际时间或“长度”不同时(参数化非均匀, Non-Uniform Parameterization),我们需要对连续性的定义进行一些调整。核心思想是:我们关心的连续性(如速度、加速度)是相对于真实时间 T 的,而不是曲线的局部参数 u 的。


1. 核心变化:全局时间 (T) vs 局部参数 (u)

首先,我们要区分两个概念:

  • 局部参数 u:这始终是从0到1变化的值,用来在单个贝塞尔曲线段内部进行插值。你的笔记中所有的 f(u) 都是基于此。
  • **全局时间 T**:这是整个样条的“真实”时间轴。

假设第 i 段曲线的开始时间是 Tᵢ,结束时间是 Tᵢ₊₁。那么该段的持续时间是 ΔTᵢ = Tᵢ₊₁ - Tᵢ
我们可以将全局时间 T 转换为该段的局部参数 u
$u = \frac{T - T_i}{T_{i+1} - T_i} = \frac{T - T_i}{\Delta T_i}$


2. 关键:使用链式法则 (Chain Rule) 重新定义连续性

物理上的速度和加速度是曲线 P 对全局时间 T 的导数,而不是对 u 的导数。我们需要使用链式法则来连接它们:

一阶导数 (速度)

$\frac{dP}{dT} = \frac{dP}{du} \cdot \frac{du}{dT}$
从上面的公式我们知道 du/dT = 1 / ΔTᵢ。所以:
$\mathbf{P}’(T) = \frac{1}{\Delta T_i} \mathbf{P}’(u)$
这意味着,真实的速度向量是 P'(u) 的一个缩放版本,缩放因子是该段时间间隔的倒数。

二阶导数 (加速度)

对一阶导数再次求导:

$$
\frac{d^2P}{dT^2} = \frac{d}{dT}\left( \frac{1}{\Delta T_i} \frac{dP}{du} \right) = \frac{1}{\Delta T_i} \cdot \frac{d}{dT}\left(\frac{dP}{du}\right)
$$

再次使用链式法则:

$$
\mathbf{P}’’(T) = \frac{1}{\Delta T_i} \cdot \frac{d}{du}\left(\frac{dP}{du}\right) \cdot \frac{du}{dT} = \frac{1}{\Delta T_i} \cdot \mathbf{P}’’(u) \cdot \frac{1}{\Delta T_i} = \frac{1}{(\Delta T_i)^2} \mathbf{P}’’(u)
$$

真实加速度的缩放因子是时间间隔平方的倒数。


3. 如何修正上述的Splines?

现在我们把这个理论应用到样条上。

A. 贝塞尔曲线的 $C^1$ 连续性

为了实现 $C^1$ 连续,需要满足 b₄ = 2b₃ - b₂,这意味着控制点 b₃b₂b₄ 的中点(所谓的 “Mirror”)。这是因为我们假设了两段曲线的 ΔT 相同,所以我们让 P'(u) 在连接点处相等。

在非均匀时间下,我们需要让真实速度 P'(T) 相等。

  • 第一段曲线(控制点 $b_0, b_1, b_2, b_3$,时长 $\Delta T_0$)在连接点 $b_3$ 的速度是:
    $\mathbf{P}’(T_1) = \frac{1}{\Delta T_0} \mathbf{P}’(u=1) = \frac{3}{\Delta T_0} (b_3 - b_2)$
  • 第二段曲线(控制点 $b_3, b_4, b_5, b_6$,时长 $\Delta T_1$)在连接点 $b_3$ 的速度是:
    $\mathbf{P}’(T_1) = \frac{1}{\Delta T_1} \mathbf{P}’(u=0) = \frac{3}{\Delta T_1} (b_4 - b_3)$

让它们相等:

$$
\frac{3}{\Delta T_0} (b_3 - b_2) = \frac{3}{\Delta T_1} (b_4 - b_3)
$$

整理后得到:

$$
\frac{b_3 - b_2}{\Delta T_0} = \frac{b_4 - b_3}{\Delta T_1}
$$

结论:控制点 b₂, b₃, b₄ 仍然需要共线,但 b₃ 不再是中点。b₃b₂b₄ 的距离之比等于两段曲线的时间长度之比:

$$
\frac{\vert \vert b_3 - b_2 \vert \vert}{\vert \vert b_4 - b_3 \vert \vert} = \frac{\Delta T_0}{\Delta T_1}
$$

“Mirror” 关系变成了一个按时间比例缩放的“加权”关系

均匀情况下的 Catmull-Rom

Catmull-Rom 的核心是它能自动计算每个点 Pᵢ 上的切线向量 vᵢ,计算方法是:
$\mathbf{v}i = \frac{1}{2} (\mathbf{P}{i+1} - \mathbf{P}_{i-1})$
这个公式的本质是一个几何上的平均。它假设从 Pᵢ₋₁Pᵢ 和从 PᵢPᵢ₊₁ 所花费的“时间”或“代价”是相同的,所以它给予了前后两个点完全相等的权重。

问题:为什么在非均匀时间下会失效?

想象一下这三个点在时间轴上的位置:

  • Pᵢ₋₁T = 0
  • PᵢT = 1
  • Pᵢ₊₁T = 10

Pᵢ₋₁Pᵢ 只花了1秒,而从 PᵢPᵢ₊₁ 花了9秒。显然,Pᵢ 点的瞬时速度应该更接近于前一段的“高速”状态,而不是被后一段“慢速”状态平均掉。均匀公式 vᵢ = 0.5 * (Pᵢ₊₁ - Pᵢ₋₁) 完全忽略了时间信息,会导致计算出的切线不符合物理直觉。

解决方案:基于“弦速度”的加权平均

为了解决这个问题,我们需要将切线的计算从几何向量升级为物理速度

  1. 计算弦速度 (Chord Velocity)
    我们把连接相邻两个点的直线段称为“弦”。我们可以计算出每根弦的平均速度。

    • Pᵢ 前一根弦的平均速度: $\mathbf{S}{i-1} = \frac{\mathbf{P}i - \mathbf{P}{i-1}}{T_i - T{i-1}} = \frac{\mathbf{P}i - \mathbf{P}{i-1}}{\Delta T_{i-1}}$
    • Pᵢ 后一根弦的平均速度: $\mathbf{S}{i} = \frac{\mathbf{P}{i+1} - \mathbf{P}i}{T{i+1} - T_i} = \frac{\mathbf{P}_{i+1} - \mathbf{P}i}{\Delta T{i}}$
  2. 平均弦速度得到切线
    最直接的非均匀 Catmull-Rom 实现,就是将这两个物理意义上的弦速度进行平均,以此作为 Pᵢ 点的切线:
    $\mathbf{v}i = \frac{1}{2} (\mathbf{S}{i-1} + \mathbf{S}{i}) = \frac{1}{2} \left( \frac{\mathbf{P}i - \mathbf{P}{i-1}}{\Delta T{i-1}} + \frac{\mathbf{P}_{i+1} - \mathbf{P}i}{\Delta T{i}} \right)$
    这个公式现在包含了时间间隔 ΔT,使得时间短(速度快)的线段对切线方向和大小的贡献更大,这远比均匀版本要合理。

均匀情况下的 B-Spline

均匀 B-Spline 的节点向量是等距的,例如 [0, 1, 2, 3, 4, 5, ...]。这导致:

  • 所有基函数(Basis Functions)B(t) 的形状都是完全一样的,只是在时间轴上进行了平移。
  • 曲线的行为在任何地方都是一致的,控制点的移动对曲线形状的影响也是可预测且均匀的。

2. 解决方案:非均匀节点向量 (Non-Uniform Knot Vector)

非均匀 B-Spline 允许节点向量中的值不均匀分布,例如 [0, 0, 0, 1.5, 3.2, 5.0, 5.0, 5.0]。这个看似简单的改变带来了巨大的灵活性。

  1. 改变基函数的形状
    B-Spline 的基函数是通过 Cox-de Boor 递归公式定义的。我们来看看这个公式(以k次样条为例):
    $B_{i,k}(t) = \frac{t - t_i}{t_{i+k} - t_i} B_{i,k-1}(t) + \frac{t_{i+k+1} - t}{t_{i+k+1} - t_{i+1}} B_{i+1,k-1}(t)$
    请注意分母中的 tᵢ₊ₖ - tᵢ。这些项是节点向量中不同节点值之间的差

    • 均匀情况下,这些差值总是相同的。
    • 非均匀情况下,这些差值是变化的!这意味着基函数的形状本身会根据节点间距的不同而改变。如果节点靠得很近,对应的基函数会变得又高又窄;如果节点分得很开,基函数会变得又矮又宽。
  2. 控制曲线的局部行为
    由于基函数的形状可以改变,我们可以通过操纵节点向量来精确控制曲线的行为:

    • 改变速度:将一段区域内的节点排得更紧密,会使曲线在这段“时间”内更快地变化,因为它要更快地经过受这些节点影响的控制点。
    • 改变连续性:这是非均匀B-Spline最强大的特性之一。在一个节点上重复多次(增加节点的“重数”,Multiplicity),可以降低该点的连续性。对于三次B-Spline(通常是 $C^2$ 连续):
      • 无重复节点 (重数为1): $C^2$ 连续 (曲率连续)。
      • 重复1次 (重数为2): 降为 $C^1$ 连续 (切线连续,但曲率不连续,可形成平滑拐角)。
      • 重复2次 (重数为3): 降为 $C^0$ 连续 (位置连续,但切线不连续,可形成尖角)。
      • 重复3次 (重数为4,即k+1次): 曲线会穿过对应的控制点。

总结:B-Spline 通过其核心定义——非均匀节点向量——来原生处理非均匀参数化

总结

概念 均匀时间 (Uniform) 非均匀时间 (Non-Uniform)
核心 局部参数 u 的导数连续 全局时间 T 的导数连续 (使用链式法则)
$C^1$ 贝塞尔 控制点 b₃b₂, b₄ 的中点 b₂, b₃, b₄ 共线,但距离比等于时间比
Catmull-Rom 切线 vᵢ = 0.5 * (Pᵢ₊₁ - Pᵢ₋₁) 需要使用更复杂的加权公式计算切线
B-Spline 使用等距的节点向量 使用非等距的节点向量,自然地处理非均匀性

Simulation Basics

Spring - Damper

$$
f = -k_s \boldsymbol{x} - k_d \boldsymbol{v}
$$

where,
$k_s = \text{spring constant}$

$\boldsymbol{x} = \text{distance from rest state}$
$x = \vert \vert \boldsymbol{x}_1 -\boldsymbol{x}_2 \vert \vert - l$
$\hat{\boldsymbol{d}} = \frac{\boldsymbol{x}_1 - \boldsymbol{x}_2}{\vert \vert\boldsymbol{x}_1 - \boldsymbol{x}_2 \vert \vert}$
$\boldsymbol{x} = x\hat{\boldsymbol{d}}$

$k_d = \text{damping factor}$
$\boldsymbol{v} = \text{velocity along spring axis}$

Aerodynamic Forces

$f_{drag} = \frac{1}{2} \rho \vert \vert \boldsymbol{v}\vert \vert ^2 c_d A \hat{\boldsymbol{v}}$

$f_{lift} = \frac{1}{2} \rho \vert \vert \boldsymbol{v}\vert \vert ^2 c_L S(\alpha - \alpha_0)$

Simulation

General Steps

  1. Compute all forces acting on each particle in the
    system in the current configuration

  2. Compute the resulting acceleration for each particle
    (a=f/m)

  3. Integrate over some small time step Dt to get new
    positions and velocities at next time step

  4. Repeat

一般的,我们有这样的关系:

$$
\ddot{\boldsymbol{x}} = \frac{1}{m} \boldsymbol{f(x, \dot{x})}
$$

或是,

$$
\dot{\boldsymbol{v}} = \frac{1}{m} \boldsymbol{f(x, v)}
$$

可以理解为,当前的加速度由当前的位移与速度决定。例如,在一个弹簧阻尼系统中,当前受到的力是位移和速度共同决定的。

我们往往想要求一阶的表示方式$\boldsymbol{x}(t)$

Phase Space

State Position (6 x 1)
$s = \left[\begin{matrix}
\boldsymbol{x} \ \boldsymbol{v}
\end{matrix}\right]$

State Velocity (6 x 1)
$s = \left[\begin{matrix}
\dot{\boldsymbol{x}} \ \dot{\boldsymbol{v}}
\end{matrix}\right]$

Particle System Dynamics (6 x 1)

$s = \left[\begin{matrix}
\boldsymbol{v} \ \frac{\boldsymbol{f}}{m}
\end{matrix}\right]$

清空旧力 → 计算新力 → 准备数据给求解器 → 用求解器更新粒子状态

Angular Momentum: $\boldsymbol{L} = \boldsymbol{r} \times \boldsymbol{p} = \boldsymbol{r} \times m \boldsymbol{v}$

角动量可以理解为物体旋转的某种“惯性”,它是一种矢量物理量,具有大小和方向

dcf8b2cce243971d559115c8d810bdf7.png

Rate of change of Angular Momentum (力矩/扭矩)

$\boldsymbol{\tau} = \frac{d\boldsymbol{L}}{dt} = \frac{d\boldsymbol{r}}{dt} \times m\boldsymbol{v} + \boldsymbol{r} \times m \frac{d\boldsymbol{v}}{dt}$

$\boldsymbol{\tau} = \boldsymbol{r} \times \boldsymbol{f}
$
力与力臂的乘积

velocity of the particle:
$\boldsymbol{v} = \frac{d\boldsymbol{r}}{dt} = \omega \times r$

当一个刚体绕固定轴旋转时,其上的点做的是圆周运动。圆周运动的速度方向是切线方向,可以通过角速度的叉乘表达出来。

$$
\mathbf{v} = \frac{d\mathbf{r}}{dt} = \dot{\mathbf{r}} = \boldsymbol{\omega} \times \mathbf{r}
$$

Acceleration:

$$
\mathbf{a} = \frac{d\mathbf{v}}{dt} = \frac{d\boldsymbol{\omega}}{dt} \times \mathbf{r} + \boldsymbol{\omega} \times \frac{d\mathbf{r}}{dt}
$$

$$
\quad = \dot{\boldsymbol{\omega}} \times \mathbf{r} + \boldsymbol{\omega} \times (\boldsymbol{\omega} \times \mathbf{r})
$$

当角加速度为零时:
$\dot{\boldsymbol{\omega}} = 0 $

  • Centripetal acceleration

$$
\mathbf{a} = \boldsymbol{\omega} \times (\boldsymbol{\omega} \times \mathbf{r})
$$

  • Centrifugal force

$$
\mathbf{f}_{\text{centrifugal}} = -m \boldsymbol{\omega} \times (\boldsymbol{\omega} \times \mathbf{r})
$$

If the length of $\mathbf{r}$ is changing as a function of time then,
Velocity:

$$
\mathbf{v} = \frac{d\mathbf{r}}{dt} = \boldsymbol{\omega} \times \mathbf{r} + \mathbf{v}_r
$$

Acceleration:

$$
\frac{d\mathbf{v}}{dt} = \frac{d\boldsymbol{\omega}}{dt} \times \mathbf{r}

  • \boldsymbol{\omega} \times \frac{d\mathbf{r}}{dt}
  • \frac{d\mathbf{v}_r}{dt}
    $$

$$
= \dot{\boldsymbol{\omega}} \times \mathbf{r}

  • \boldsymbol{\omega} \times \dot{\mathbf{r}}
  • \dot{\mathbf{v}}_r
    $$

因为:

$$
\dot{\mathbf{r}} = \boldsymbol{\omega} \times \mathbf{r} + \mathbf{v}_r
$$

所以:

$$
\mathbf{a} = \dot{\boldsymbol{\omega}} \times \mathbf{r} + \boldsymbol{\omega} \times (\boldsymbol{\omega} \times \mathbf{r}) + \dot{\mathbf{v}}_r
$$

Rewriting Angular Momentum

$$
\mathbf{L} = \mathbf{r} \times \mathbf{p} = \mathbf{r} \times (m\mathbf{v}) = m\mathbf{r} \times \mathbf{v} = m\mathbf{r} \times (\boldsymbol{\omega} \times \mathbf{r}) = -m \mathbf{r} \times \mathbf{r} \times \boldsymbol{\omega}
$$

这里的 𝑟 表示的是:

质点(或物体上某一点)相对于旋转轴/参考点的位置向量。

🔹 Replace $\mathbf{r} \times$ with a cross product equivalent matrix $\boldsymbol{\Gamma}$ , where:

$$
\boldsymbol{\Gamma} =
\begin{bmatrix}
0 & -r_z & r_y \
r_z & 0 & -r_x \
-r_y & r_x & 0
\end{bmatrix}
$$

🔹 This allows:

$$
\mathbf{L} = -m \mathbf{r} \times (\mathbf{r} \times \boldsymbol{\omega}) = -m \boldsymbol{\Gamma} \cdot \boldsymbol{\Gamma} \cdot \boldsymbol{\omega}
$$

🔹 Define Moment of Inertia Matrix:

$$
\mathbf{I} = -m \boldsymbol{\Gamma} \cdot \boldsymbol{\Gamma}
$$

🔹 Results in the Angular Momentum equation:

$$
\mathbf{L} = \mathbf{I} \cdot \boldsymbol{\omega}
$$

Integration

当我们知道一个关系后,例如first-order ODE form:

$\dot{\boldsymbol{x}} = \boldsymbol{f(x,u)}$ ,我们想知道 $\boldsymbol{x}(t)$

根据泰勒展开公式,有

$$
\mathbf{x}(t_0 + \Delta t) =
\mathbf{x}(t_0) +
\frac{d\mathbf{x}}{dt}(t_0) \Delta t +
\frac{1}{2!} \frac{d^2 \mathbf{x}}{dt^2}(t_0) \Delta t^2 +
\frac{1}{3!} \frac{d^3 \mathbf{x}}{dt^3}(t_0) \Delta t^3 + \cdots
$$

换一种写法:用离散帧时间 $t_k$

可以把时间离散成一帧一帧(每一帧时长为 $\Delta t$):

$$
\mathbf{x}(t_{k+1}) \approx \mathbf{x}(t_k) +
\dot{\mathbf{x}}(t_k)\Delta t +
\frac{1}{2!} \ddot{\mathbf{x}}(t_k)\Delta t^2 +
\frac{1}{3!} \dot{\ddot{\mathbf{x}}}(t_k)\Delta t^3 + \cdots
$$

高次的项可以由链式法则求得

仅保留一阶的项时,有

$$
\mathbf{x}(t_{k+1}) \approx \mathbf{x}(t_k) +
\dot{\mathbf{x}}(t_k)\Delta t
$$

此处,

$$
\dot{\mathbf{x}}(t_k) = \mathbf{f(x(t_k), u(t_k) )}
$$

这样做的话如果时间间隔过长,会很不稳定。

我们考虑使用 $2^{nd} \text{Order Runge Kutta}$
Step 1.
使用欧拉方法计算 预测值

$$
\mathbf{x}^p(t_{k+1}) = \mathbf{x}(t_k) + \dot{\mathbf{x}}(t_k)\Delta t
$$

而后,讲此预测值带入到关系中,有

$$
\dot{\mathbf{x}}^p(t_{k+1}) = f(\mathbf{x}^p(t_{k+1}), \mathbf{u}(t_k))
$$

最后求取一个平均值

$$
\mathbf{x}(t_{k+1}) = \mathbf{x}(t_k) + \frac{\Delta t}{2} \left[
\dot{\mathbf{x}}(t_k) + \dot{\mathbf{x}}^p(t_{k+1})
\right]
$$

4ᵗʰ Order Runge Kutta

$$
\mathbf{x}(t_{k+1}) = \mathbf{x}(t_k) + \frac{1}{6} \left[ \mathbf{d}_1 + 2\mathbf{d}_2 + 2\mathbf{d}_3 + \mathbf{d}_4 \right]
$$


🔹 Where:

$\mathbf{d}_1 = \Delta t \cdot \dot{\mathbf{x}}(t_k) = \Delta t \cdot \mathbf{f}(\mathbf{x}(t_k), \mathbf{u}(t_k))$

$\mathbf{d}_2 = \Delta t \cdot \mathbf{f} \left(t_k + \frac{\Delta t}{2}, \mathbf{x}(t_k) + \frac{1}{2}\mathbf{d}_1 \right)$

$\mathbf{d}_3 = \Delta t \cdot \mathbf{f} \left(t_k + \frac{\Delta t}{2}, \mathbf{x}(t_k) + \frac{1}{2}\mathbf{d}_2 \right)$

$\mathbf{d}_4 = \Delta t \cdot \mathbf{f} \left(t_k + \Delta t, \mathbf{x}(t_k) + \mathbf{d}_3 \right)$

🔁 Backwards Euler:

$\mathbf{x}(t_k) = \mathbf{x}(t_{k+1}) - \dot{\mathbf{x}}(t_{k+1}) \Delta t$

or equivalently:

$\mathbf{x}(t_{k+1}) = \mathbf{x}(t_k) + \dot{\mathbf{x}}(t_{k+1}) \Delta t$

问题是,我们怎么算 $\dot{\mathbf{x}}(t_{k+1})$

①根据我们已知的关系,
我们${\boldsymbol{\dot {x}}(t_{k+1})} = \boldsymbol{f}(\boldsymbol{x}(t_{k+1}))$

但是我们不知道$\boldsymbol{x}(t_{k+1})$, 我们使用RK2来估算这个值

②一般的,我们使用泰勒展开

$$
\boldsymbol{x}(t_{k+1}) = \boldsymbol x(t_k) + \Delta t \cdot \dot{ \boldsymbol{x}}(t_{k+1}) \ = \boldsymbol{x}(t_{k}) + \Delta \boldsymbol{x}(t_k)
$$

$$
\dot{\mathbf{x}}(t_{k+1}) = \boldsymbol{f}(\boldsymbol{x}(t_{k+1})) = \boldsymbol{f}(\boldsymbol{x}(t_{k}) + \Delta \boldsymbol{x}(t_k))
$$

$$
\boldsymbol{x}(t_{k}) + \Delta \boldsymbol{x}(t_k) = \boldsymbol{x}(t_k) + \Delta t \cdot \boldsymbol{f}(\boldsymbol{x}(t_{k}) + \Delta \boldsymbol{x}(t_k))
$$

$$
\mathbf{x}(t_{k+1}) = \mathbf{x}(t_k) + \Delta \mathbf{x}(t_k)
$$

$$
\mathbf{x}(t_{k+1}) = \mathbf{x}(t_k) +
\left( \frac{1}{\Delta t} \mathbf{I} - \frac{\partial \mathbf{f}}{\partial \mathbf{x}} (t_k) \right)^{-1}
\mathbf{f}(\mathbf{x}(t_k))
$$

设:

$$
\mathbf{f}(x, y) =
\begin{bmatrix}
f_1(x, y) = x^2 + y \
f_2(x, y) = \sin(xy)
\end{bmatrix}
$$

则 Jacobian 是:

$$
\frac{\partial \mathbf{f}}{\partial \mathbf{x}} =
\begin{bmatrix}
\frac{\partial f_1}{\partial x} & \frac{\partial f_1}{\partial y} \
\frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y}
\end{bmatrix}

\begin{bmatrix}
2x & 1 \
y\cos(xy) & x\cos(xy)
\end{bmatrix}
$$

Adams Bashforth

🔹 Keep terms up to 2ⁿᵈ order in Taylor series approx

$$
\mathbf{x}(t_{k+1}) = \mathbf{x}(t_k) + \dot{\mathbf{x}}(t_k),\Delta t

  • \frac{1}{2!},\ddot{\mathbf{x}}(t_k),\Delta t^2
    $$

🔹 Approximate the second derivative as

$$
\ddot{\mathbf{x}}(t_k)
\approx
\frac{\dot{\mathbf{x}}(t_k) - \dot{\mathbf{x}}(t_{k-1})}{\Delta t}
$$


🔹 Which yields

$$
\mathbf{x}(t_{k+1})
= \mathbf{x}(t_k)

  • \frac{\Delta t}{2},\bigl[3,\dot{\mathbf{x}}(t_k) - \dot{\mathbf{x}}(t_{k-1})\bigr]
    $$

Verlet Integration

🔹 Does not require velocity to be explicitly computed


🔹 Given the current value of $\mathbf{x}(t_k)$, compute $\mathbf{x}$ at the next time step $t_{k+1}$ and previous time step $t_{k-1}$using a 2ⁿᵈ order approximation:

$$
\mathbf{x}(t_{k+1}) = \mathbf{x}(t_k) + \dot{\mathbf{x}}(t_k) \Delta t + \frac{1}{2} \ddot{\mathbf{x}}(t_k) \Delta t^2
$$

$$
\mathbf{x}(t_{k-1}) = \mathbf{x}(t_k) - \dot{\mathbf{x}}(t_k) \Delta t + \frac{1}{2} \ddot{\mathbf{x}}(t_k) \Delta t^2
$$


🔹 Adding the two together and rearranging yields:

$$
\mathbf{x}(t_{k+1}) = 2\mathbf{x}(t_k) - \mathbf{x}(t_{k-1}) + \ddot{\mathbf{x}}(t_k) \Delta t^2
$$

Complex number & Quaternion

Refer to: quaternion.pdf quaternion by Krasjet

坐标系

本文将使用右手坐标系统, 用右手定则来定义旋转的正方向,即右手拇指指向旋转轴的 $\bf{u}$ 的正方向,这时其它 四个手指弯曲的方向即为旋转的正方向。

基础

  • 点乘,标量积

image-20240402185134356

image-20240402185139431

image-20250601221311402

可以看到,$\boldsymbol{v}_{||}$其实就是 $\boldsymbol{v}$ 在 $\boldsymbol{u}$ 上的正交投影(OrthogonalProjection),根据 正交投影的公式,我们可以得出:

image-20240326165917260

我们需要处理正交于 $\boldsymbol{u}$ 的 $\bf v_{\perp}$

image-20240326165932015

image-20240402200036104

方向永远垂直于矢量ab所在的平面啊。具体方向由右手定则决定。四指跟从向量A和B的旋转方向时,大拇指所朝的方向。

image-20240326170558559

image-20240402200823711

对于四元数 $q = a + bi +cj + dk$, $i^{2} = j^{2} = k^{2} = ijk = -1$

逆和共轭

$q^{-1}$是$q$的逆,规定:

$$
qq^{-1} = q^{-1} q = 1 (q \neq 0)
$$

$$
(pq)q^{-1} = p(qq^{-1}) = p \cdot 1 = p \
q^{-1}(qp) = (q^{-1}q)p = 1 \cdot p = p
$$

image-20250601161211002

image-20240402201419473

如果 $|| q || = 1$, 也就是说 $q$ 是一个单位四元数,那么

$$
q^{-1} = \frac{q ^*}{1^{2}} = q^* = [s, \bf{-v}]
$$

旋转

image-20240403121643843

换言之,当我们有 $q= [\cos(\theta), \sin(\theta)\bf{u}]$时,那么$v’ = qvq^*$ 可以将 $\bf{v}$ 沿着 $\bf{u}$ 旋转 2$\theta$ 度。

我们把 $\bf{v}$ 写作一个纯四元数,然后按照四元数乘法进行运算,注意,四元数乘法有结合律,但四元数乘法通常没有交换律。

左乘一个四元数 $q = a + bi +cj + dk$ 等同于左乘下面这个矩阵:

$$
L(q) = \begin{bmatrix}
a & -b & -c & -d \\
b & a & -d & c \\
c & d & a & -b \\
d & -c & b & a
\end{bmatrix}
$$

右乘 $q$ 等同于这个左乘这个矩阵:

$$
R(q) = \begin{bmatrix}
a & -b & -c & -d \\
b & a & d & -c \\
c & -d & a & b \\
d & c & -b & a
\end{bmatrix}
$$

我们有:$qvq^* = L(q)R(q^*)v = R(q^*)L(q)v$, 注意$R(q^*) = R(q)^{T}$

也就是:

$$
qvq^* = \begin{bmatrix}
1 & 0 & 0 & 0 \
0 & 1 - 2c^2 - 2d^2{} & 2bc - 2ad & 2ac + 2bd \
0 & 2bc + 2ad & 1 - 2b^2 - 2d^2 & 2cd - 2ab \
0 & 2bd - 2ac & 2ab + 2cd & 1 - 2b^2 - 2c^2
\end{bmatrix} v
$$

四元数, 轴-角, 旋转矩阵, 欧拉角转换

任意向量 $\bf{v}$ 沿着以单位向量定义的旋转轴 $\bf{u}$ 旋转 $\theta$ 角度之后的 $\bf{v’}$ 可以使用矩阵乘法来获得。

令 $v = [0, \boldsymbol{v} ], q = [\cos(\frac{\theta}{2}), \sin(\frac{\theta}{2})\boldsymbol{u}]$

四元数与3D旋转的关系并不是一对一的,同一个3D旋转可以使用两个不同的四元数来表示。
对于任意的$q = [\cos(\frac{\theta}{2}), \sin(\frac{\theta}{2})\boldsymbol{u}]$,$q$与$-q$ 代表的是同一个旋转。如果𝑞表示的是沿着旋转轴$\bf{u}$旋转θ度,那么−𝑞代表的是沿着相反的旋转轴$- \bf{u}$旋转(2𝜋−θ)度。

对于常见的 ZYX 顺序(Unity Inspector 中使用的顺序,对应先绕Y轴,再绕X轴,再绕Z轴应用旋转,或者说物体先绕自身的Z轴,再绕自身新的X轴,再绕自身新的Y轴): $q_X=[cos(α/2),sin(α/2)(1,0,0)]$ $q_Y=[cos(β/2),sin(β/2)(0,1,0)]$ $q_Z=[cos(γ/2),sin(γ/2)(0,0,1)]$ 最终的四元数 $q=q_Y⋅q_X⋅q_Z$ (注意乘法顺序,取决于欧拉角的具体定义是内旋还是外旋,以及轴的顺序)。

这个转换是明确的:给定一组特定顺序的欧拉角,总能计算出唯一的一个四元数(不考虑 q 和 −q 的等价性)。

插值

假设有两个旋转变换 $q_0 = [\cos(\frac{\theta_0}{2}), \sin(\frac{\theta_0}{2})\boldsymbol{u_0}]$, $q_1 = [\cos(\frac{\theta_1}{2}), \sin(\frac{\theta_1}{2})\boldsymbol{u_1}]$, 我们希望找出一些中间变换$q_t$, ,让初始变换$q_0$能够平滑地过渡到最终变换$q_1$.𝑡 的取值可以是𝑡 ∈[0,1]

image-20250602081526372

旋转的变化量其实对应的仍是一个旋转。其将由 $q_0$ 变换到 $v_0$ 的向量进一步旋转到 $v_t$ 这个旋转拥有某一个固定的旋转轴 $\bf u_t$,,我 们只需要缩放这个变换所对应的角度𝜙就能够达到插值的目的了。

我们该怎么获得这个旋转的变化量呢?

考虑一下什么变换Δ𝑞能将已经旋转到$v_0$的向量$v$直接变换到$v_1$, 这其实是一个旋转的复合,我们先进行$q_0$变换,再进行Δ𝑞变换,它们复合的结果 需要等于$q_1$变换, 即

$$
\Delta q q_0 = q_1 \\
\Delta q q_0 q_0 ^{-1} = q_1 q_0 ^{-1}
$$

而所有的旋转$q$都是单位四元数,有$q^{-1} = q^*$,上式又可以写作

$$
\Delta q = q_1 q_0^*
$$

Lerp

$$
q_t = Lerp(q_0, q_1, t) = (1-t)q_0 + tq_1
$$

我们是沿着一条直线(也就是圆上的一个弦)进行插值的,这样 插值出来的四元数并不是单位四元数

image-20250602101739678

Nlerp

虽然这样插值出来的 $q_t$ 并不是单位四元数,但只要将$q_t$除以它的模长 $||q_t||$ 就能够将其转化为一个单位四元数了

image-20250602103435098

$$
\boldsymbol{v}_t = Nlerp(\boldsymbol{v}_0, \boldsymbol{v}_1, t) = \frac{(1-t)\boldsymbol{v}_0 + t \boldsymbol{v}_1}{|| (1-t)\boldsymbol{v}_0 + t \boldsymbol{v}_1||} \
\boldsymbol{q}_t = Nlerp(\boldsymbol{q}_0, \boldsymbol{q}_1, t) = \frac{(1-t)\boldsymbol{q}_0 + t \boldsymbol{q}_1}{|| (1-t)\boldsymbol{q}_0 + t \boldsymbol{q}_1||}
$$

Nlerp 插值仍然存在有一定的问题,当需要插值的弧比较大时,$\boldsymbol{v}_t$的角速 度会有显著的变化.我们可以来看一个例子:

image-20250602103645045

这五个𝑡值将整个弧和弦分割成了四个部分.虽然弦上的四段是等长的,但 是四个弧是完全不相等的.𝑡=0到𝑡=0.25之间的弧(红色)明显比𝑡=0.25 到𝑡 =0.50的弧(蓝色)要短了不少

Slerp

为了解决这个问题,我们可以转而对角度进行线性插值.也就是说,如果 $\boldsymbol{v}_1$ 和$\boldsymbol{v}_2$之间的夹角为θ,那么

$$
\theta_t= (1-t) \cdot 0 + t \theta = t \theta
$$

$$
q_t = Slerp(q_0, q_1, t) = \frac{\sin((1-t)\theta)}{\sin(\theta)} q_0 + \frac{\sin(t\theta)}{\sin(\theta)} q_1
$$

其中,

$$
\theta =\cos^{-1}(q_0 \cdot q_1)
$$

注意,这里的 $q_0 \cdot q_1$ 是点积,其结果是一个标量,计算方法为:

$$
q_0 \cdot q_1 = w_0w_1 + x_0x_1 + y_0 y_1 + z_0z_1
$$

而因为任意两个向量(包括四位四元数向量),它们的点积与它们之间的夹角 $\theta$ 以及它们各自的模长(长度)有关:

$$
q_0 \cdot q_1 = ||q_0|| \cdot ||q_1|| \cos(\theta)
$$

如果我们需要对多个四元数进行插值,对每一对四元数使 用Slerp 插值虽然能够保证每两个四元数之间的角速度是固定的,但是角速 度会在切换插值的四元数时出现断点,或者说在切换点不可导。

Squad

$$
Squad(q_0, q_1, q_2, q_3;t) = Slerp(Slerp(q_0, q_3;t),Slerp(q_1, q_2; t); 2t(1-t))
$$

常见 API

1
public static Quaternion LookRotation (Vector3 forward, Vector3 upwards= Vector3.up);

使用指定的 forward 和 upwards 方向创建旋转。 本地的 Z 轴将与给定的,世界中的forward向量对齐,本地的X 轴与 forward 和 upwards 之间的差积对齐,Y 轴与 Z 和 X 之间的差积对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test: MonoBehaviour{
public Transform sphere;

void Update(){
Vector3 dir = sphere.position - transform.position; //正方体指向球体的向量dir = 球体坐标 - 正方体坐标
Quaternion ang = Quaternion.LookRotation(dir); //创建一个 使正方体朝向球体的旋转角
transform.rotation = ang; //使正方体朝向球体

Debug.DrawRay(transform.position, transform.forward * 100, Color.blue); //绘制正方体forward
Debug.DrawRay(transform.position, dir, Color.green); //绘制向量dir
Debug.DrawRay(transform.position, ang.eulerAngles, Color.red);
}
}

场景运行Gif

我们需要构建这样一个坐标系,换言之,其被“旋转为”:

  • Z轴(前方向,z_axis),也就是输入的forward

  • X轴(右方向, x_axis),此轴追至于刚刚算出来的Z轴,并垂直于upwards, 我们通过叉乘得到

    1
    Vector3 x_axis = Vector3.Normalize(Vector3.Cross(upwards, z_axis));
  • Y轴(上方向,y_axis),此轴垂直于刚刚运算得到的Z轴与X轴,

    1
    y_axis = Vector3.Cross(z_axis, x_axis);

我们期望的旋转矩阵$M$的作用:

我们希望这个矩阵 $M$ 能够将物体的局部坐标系中的向量转换到世界坐标系中。 换句话说,当我们用$M$左乘一个局部坐标向量时,我们应该得到这个向量在世界坐标系中的表示。

也就是说,

  • $M \cdot \mathbf{e}{\mathbf{x}{\text{local}}} = M\begin{pmatrix} 1 \\ 0 \\ 0 \end{pmatrix}$ 此结果为$M$的第一列
  • $M \cdot \mathbf{e}{\mathbf{y}{\text{local}}} = M\begin{pmatrix} 0 \\ 1 \\ 0 \end{pmatrix}$此结果为$M$的第二列
  • $M \cdot \mathbf{e}{\mathbf{z}{\text{local}}} = M\begin{pmatrix} 0 \\ 0 \\ 1 \end{pmatrix}$此结果为$M$的第三列

一个从A坐标系到B坐标系的变换矩阵,其列向量就是A坐标系的基向量在B坐标系中的表示。即使不存在一组欧拉角能够精确构成这个特定的变换矩阵 $M$

$M = \begin{bmatrix} x_{axis}.x & y_{axis}.x & z_{axis}.x \\ x_{axis}.y & y_{axis}.y & z_{axis}.y \\ x_{axis}.z & y_{axis}.z & z_{axis}.z \end{bmatrix}$

假设A是标准的右手坐标系。考虑一个简单的反射变换 $M = \begin{pmatrix} -1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{pmatrix}$ (沿YZ平面反射,即X轴反向)。 A的X轴 $(1,0,0)_A$ 变换后是 $(-1,0,0)_B$ —— 这是 $M$ 的第一列。 A的Y轴 $(0,1,0)_A$ 变换后是 $(0,1,0)_B$ —— 这是 $M$ 的第二列。 A的Z轴 $(0,0,1)_A$ 变换后是 $(0,0,1)_B$ —— 这是 $M$ 的第三列。 由 ${(-1,0,0)_B, (0,1,0)_B, (0,0,1)_B}$ 构成的坐标系B现在是一个左手坐标系。

尝试打包WebGL平台遇到的坑

在将一个 Unity 2022.3 数字孪生项目(利用Unity3D_Robotics_ABB)从原 Win 平台迁移到 WebGL 平台的过程中,遇到了 RuntimeError: null function or function signature mismatchThe script 'xxxxx' could not be instantiated!digest auth failed 等等报错和故障。在对网上的解决方案探索和尝试的过程中,有小结如下

System.Threading 相关命名空间(C# 原生线程)不支持

这是着手切换WebGL平台之前就知道不行的一点,不过使用 UniTask 可以替换。

Unity3D_Robotics_ABB 作者在其实现中主要使用了原生的 Thread 来处理异步的逻辑,在 Unity 打 WebGL 包时并没有报错且能成功出包,然而,在运行 WebGL 的程序时(尤其做某些会拉起线程的操作时),会出现卡死、弹窗报错RuntimeError: null function or function signature mismatch 的问题。

若出现上述问题,应该考虑利用 Unity 托管的异步实现(UniTask、协程等),或 async/await 关键词处理对应需求。

System.Net 相关命名空间不支持(需要利用Unity提供的 UnityWebRequest(UWR) 来进行 HTTP 请求)

这是解决问题花费时间比较长的一点。

在解决完线程的问题后,运行 WebGL 程序,发现会出现 The script 'xxxxx' could not be instantiated! 等控制台警告,且此脚本功能失效。经过测试后,发现脚本中若引用了 System.Net 等 .NET networking classes,则此脚本不能被正确加载,见 WebGL Networking ,此时需要使用 UnityWebRequest 类进行 http 请求。

由于 UnityWebRequest 没有对 Authentication 的支持,需要手动处理一些问题:digest auth failed 。例如,Basic 验证方案可以自己加Header 并存入对应值。然而,对于 Digest 验证方案,手动处理比较麻烦。此时考虑调 JavaScript 的加 Digest 验证的方法并发送 HTTP 请求,或者由代理服务器添加 Digest 验证

技术水平有限,最终选择了由代理服务器添加 Digest 验证的方法,下面是对应的 Python 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from flask import Flask, request, Response
import requests
from requests.auth import HTTPDigestAuth

app = Flask(__name__)

# The URL of the API that requires Digest Authentication
TARGET_API_URL = "http://www.tsingloo.com"
# Replace 'your_username' and 'your_password' with your actual credentials
USERNAME = 'your_username'
PASSWORD = 'your_password'

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
def proxy(path):
# Construct the full URL to the target API
url = f"{TARGET_API_URL}/{path}"

# Log the requested URL
app.logger.info(f"I am requesting {url}")

# Get the original request method and data
method = request.method
data = request.get_data()

# Selectively copy headers, excluding those that can cause issues
excluded_headers = ['Host', 'Content-Length', 'Content-Type']
headers = {key: value for key, value in request.headers if key not in excluded_headers}

# If the request has a content type, include it in the headers
if 'Content-Type' in request.headers:
headers['Content-Type'] = request.headers['Content-Type']

# Inside your proxy function
response = requests.request(method, url, data=data, headers=headers)

# Make sure to copy the content-type header from the original response to the new response
content_type = response.headers.get('Content-Type')
response_headers = {'Content-Type': content_type} if content_type else {}

return Response(response.content, status=response.status_code, headers=response_headers)

@app.before_request
def log_request():
print(f"Received request: {request.url} - {request.method}")

if __name__ == '__main__':
app.run(debug=True)

短时间内利用 UWR 对 RWS 发送大量请求

为了追求项目的实时性,短时间内 Unity 利用 UWR 向机械臂(RWS)发送大量请求,前70个请求成功,而后的请求报 “503 Service Unavailable (Too many sessions 70/70)”错,且当双方断开连接时,仍需要一段时间“冷却”,而后才能请求正确收到回复,然后短时间内约70个 UWR 的 http 的请求后,重复上述 503 错误。使用C#提供的 HTTP 相关类时未见此问题。当503报错时,关闭代理服务器,重新插拔网线,都不能立刻释放此70个sessions。

值得注意的是,当 UWR 已经收到503报错时,再继续使用Postman测试相关接口,如(http://192.168.x.x/rw/rapid/tasks/T_ROB1/motion?resource=jointtarget)时,Postman有时可以正常收到数据。复盘此情况,推测是在排障中,由 Postman 已经发送了带 http头”Connection: Keep-Alive”并成功建立了正常的连接,早于 UWR 的请求,换言之,其 70 个session中已经维护了一个与postman的长连接。冷却后,使用 Postman 短时间内大量测试相关接口可以持续获得正常的数据,未见503报错。

冷却后,当使用Edge浏览器短时间内大量访问接口,前70个请求成功,而后的也会503报错,表现与 UWR 一样,且观察浏览器发出的 HTTP 请求,其已携带”Connection: Keep-Alive”,不同的是,浏览器发出的 HTTP 带有很长的“User-Agent”头,这一点没弄明白。

冷却后,当使用 Python 对接口短时间内大量发送简单的http请求(不挟带请求头”Connection: Keep-Alive”)时,前70个请求成功,而后的也会503报错,表现与 UWR 一样。

经检查,(编辑器 2022.3 版本默认的) UWR 发出的 HTTP 请求默认不携带标头 Connection: Keep-Alive,有讨论,见Connection: keep-alive in Unity Web Request?,且当使用 UnityWebRequest.SetRequestHeader 手动设置此标头时,会见警告且官方文档不推荐如此做。

最终,通过 UnityWebRequest.SetRequestHeader 手动设置标头”Connection: Keep-Alive”,解决了上述 503 报错。

几乎同一时刻对 RWS 发送多个请求

在将原线程替换为 UniTask 后,某个方法每帧将同时对 RWS 发送三个不同的 HTTP 请求,导致报 “Connection manager can’t add .. ” 错,改为一帧(约23ms)轮流发一个 HTTP 请求后解决。

HttpClient.PostAsyncUnityWebRequest.Post

使用PostAsync时,需要传入一个string和一个httpcontent,在使用 .Post 时,可以是两个string,也可以是一个string和一个 WWWForm,推荐使用后者,可以规避一些可能的编码问题。

反序列化时 dynamic 关键字引起的崩溃

项目中使用从 Unity Package Manager 中添加的 Newtonsoft.Json 来处理请求,原本从字符串中反序列化对象代码如下:

1
2
dynamic obj = JsonConvert.DeserializeObject(jsonStr);
var state = obj.name;

初次尝试打WebGL包时会报缺少某命名空间错,而后在项目设置中将 API 兼容性 级别改为 .Net Framewrok 后不再报错。
上述代码会导致WebGL运行时弹窗报错奔溃,推荐参考 json 的内容定义类型而后获取对应字段的值(JSON2CSharp)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[Serializable]
public class People
{
public string name;
public int age;
public DateTime birthday;
public bool isMarried;
public float money;
}

public void DeserializeJson()
{
string jsonStr = "{\"name\" :\"张三\",\"age\":30,\"birthday\":\"1990-03-31T17:15:29\",\"isMarried\":true,\"money\":10.0}";

People zhangsan = JsonConvert.DeserializeObject<People>(jsonStr);
Debug.Log("zhangsan.name " + zhangsan.name + " birthday: " + zhangsan.birthday + " isMarried: " + zhangsan.isMarried);

//output: zhangsan.name 张三 birthday: 03/31/1990 17:15:29 isMarried: True
}