摘要
Ray-AABB問題:判斷線段是否相交於軸對齊邊界框(Axially Aligned Bounding Box, AABB)
本文介紹了slab算法的實現,從一個簡單實現開始,逐步優化slab算法,算法加速和並進行邊界情況處理。
本文轉載並翻譯自:
- FAST, BRANCHLESS RAY/BOUNDING BOX INTERSECTIONS
- FAST, BRANCHLESS RAY/BOUNDING BOX INTERSECTIONS, PART 2: NANS
第一部分:簡單實現
軸對齊包圍盒(AABB)通常用於限制光線跟蹤中的有限對象。 射線/ AABB交點通常比精確的射線/對象交點計算得更快,並允許構造邊界體積層次結構(BVH),減少了每條射線需要考慮的對象數量。 (這將在以後的文章中詳細介紹BVH。)這意味着光線跟蹤器會花費大量時間來計算光線/ AABB交點,因此應高度優化此代碼。
進行ray / AABB相交的最快方法是slab方法。 想法是將盒子視爲三對平行平面內的空間。 射線被每對平行平面夾住,並且如果射線的任何部分保留下來,它將與盒子相交。
v1.0 簡單實現版本
此算法的簡單實現可能看起來像這樣(爲簡潔起見,在兩個維度中):
bool intersection(box b, ray r) {
double tmin = -INFINITY, tmax = INFINITY;
if (ray.n.x != 0.0) {
double tx1 = (b.min.x - r.x0.x)/r.n.x;
double tx2 = (b.max.x - r.x0.x)/r.n.x;
tmin = max(tmin, min(tx1, tx2));
tmax = min(tmax, max(tx1, tx2));
}
if (ray.n.y != 0.0) {
double ty1 = (b.min.y - r.x0.y)/r.n.y;
double ty2 = (b.max.y - r.x0.y)/r.n.y;
tmin = max(tmin, min(ty1, ty2));
tmax = min(tmax, max(ty1, ty2));
}
return tmax >= tmin;
}
但是,這些部門要花很多時間。 由於在進行射線追蹤時,同一束射線是針對許多ABB進行測試的,因此預先計算射線方向分量的逆數是有意義的。 如果我們可以依靠IEEE 754浮點屬性,則這也可以隱式處理方向分量爲零的邊緣情況-例如,如果射線在射線的範圍內,則tx1和tx2值將是相反符號的無窮大。 平板,因此tmin和tmax保持不變。 如果光線在平板之外,則tx1和tx2將是具有相同符號的無限大,從而使tmin == + inf
或tmax == -inf
,從而導致測試失敗。
v1.1 版本
最終的實現如下所示:
bool intersection(box b, ray r) {
double tx1 = (b.min.x - r.x0.x)*r.n_inv.x;
double tx2 = (b.max.x - r.x0.x)*r.n_inv.x;
double tmin = min(tx1, tx2);
double tmax = max(tx1, tx2);
double ty1 = (b.min.y - r.x0.y)*r.n_inv.y;
double ty2 = (b.max.y - r.x0.y)*r.n_inv.y;
tmin = max(tmin, min(ty1, ty2));
tmax = min(tmax, max(ty1, ty2));
return tmax >= tmin;
}
由於現代浮點指令集可以在沒有分支的情況下計算最小值和最大值,因此可以進行無分支或除法的ray / AABB交集測試。
在我寫的光線追蹤器 Dimension 中對此的實現可以在這裏看到。
第二部分:考慮線段與平板重合的情況
在第1部分中,我概述了一種用於計算光線和與軸對齊的邊界框之間的交點的算法。 依靠IEEE 754浮點屬性消除分支的想法可以追溯到[1]中的Brian Smits,該實現被Amy Williams充實了。 等。 在[2]中。
v2.0
爲了快速回顧一下,該想法是替換樸素的平板方法:
bool intersection(box b, ray r) {
double tmin = -INFINITY, tmax = INFINITY;
for (int i = 0; i < 3; ++i) {
if (ray.dir[i] != 0.0) {
double t1 = (b.min[i] - r.origin[i])/r.dir[i];
double t2 = (b.max[i] - r.origin[i])/r.dir[i];
tmin = max(tmin, min(t1, t2));
tmax = min(tmax, max(t1, t2));
} else if (ray.origin[i] <= b.min[i] || ray.origin[i] >= b.max[i]) {
return false;
}
}
return tmax > tmin && tmax > 0.0;
}
v2.1
v2.0的等價寫法,但是比v2.0要更快:
bool intersection(box b, ray r) {
double tmin = -INFINITY, tmax = INFINITY;
for (int i = 0; i < 3; ++i) {
double t1 = (b.min[i] - r.origin[i])*r.dir_inv[i];
double t2 = (b.max[i] - r.origin[i])*r.dir_inv[i];
tmin = max(tmin, min(t1, t2));
tmax = min(tmax, max(t1, t2));
}
return tmax > max(tmin, 0.0);
}
這兩種算法真的等效嗎? 我們已經依靠IEEE 754浮點行爲消除了 檢查。 當 時, 。如果射線原點的座標在框內,則意味着 ,我們將得到 。 由於對於所有 的,因此tmin和tmax將保持不變。
另一方面,如果 $i r.origin_i <b.min_i$ 或 ),則 ,因此 。 這些值之一將貫穿算法的其餘部分,從而導致我們返回false。
不幸的是,上述分析有一個警告:如果射線正好位於平板上(),我們將擁有(說)
這表現得比無窮大得多。 正確處理此邊緣(字面意義!)的情況取決於min()和max()的確切行爲。
關於min()和max()
min()和max()的最常見實現可能是
#define min(x, y) ((x) < (y) ? (x) : (y))
#define max(x, y) ((x) > (y) ? (x) : (y))
這種形式是如此普遍,以至於被稱爲SSE / SSE2指令集中的最小/最大指令的行爲。 使用這些指令是從算法中獲得良好性能的關鍵。 話雖如此,這種形式具有涉及NaN的某些奇怪行爲。 由於所有與NaN的比較都是錯誤的,
這些操作既不傳播也不抑制NaN。 相反,當其中一個參數爲NaN時,總是返回第二個參數。 (關於有符號零,也有類似的奇數行爲,但這並不影響此算法。)
相反,IEEE 754指定的最小/最大操作(稱爲“ minNum”和“ maxNum”)抑制NaN,如果可能的話總是返回一個數字。 這也是C99的fmin()和fmax()函數的行爲。 另一方面,Java的Math.min()和Math.max()函數傳播NaN,與大多數其他對浮點值的二進制運算保持一致。 [3]和[4]對野外的各種最小/最大實現進行了更多討論。
存在問題
min()和max()的IEEE和Java版本提供一致的行爲:恰好位於板上的所有光線均被視爲不與盒子相交。 很容易理解爲什麼要使用Java版本,因爲NaN最終會污染所有計算,並使我們返回false。 對於IEEE版本,,與射線完全在盒子外面時相同。
(由於這是一個極端的情況,您可能想知道爲什麼我們不選擇對邊界上的光線返回true而不是false。事實證明,使用高效代碼來實現此行爲要困難得多。)
使用對SSE友好的最小/最大實現,行爲是不一致的。 平板上的某些光線會相交,即使它們完全位於盒子的另一個維度之外:
在上面的圖像中,相機位於立方體的頂面的平面中,並且使用上述算法計算了交點。 由於不正確的NaN處理,頂面超出了側面。
v2.2 解決方案
當最多一個參數是NaN時,我們可以使用
Thierry Berger-Perrin在[5]中採用了類似的策略,可以有效地進行計算
tmin = max(tmin, min(min(t1, t2), INFINITY));
tmax = min(tmax, max(max(t1, t2), -INFINITY));
在循環中。 這樣做也很好。由於CPU處理浮點特殊情況(無窮大,NaN,次正規)的速度較慢,因此改成下面這樣速度要比上面快30%左右。
tmin = max(tmin, min(min(t1, t2), tmax));
tmax = min(tmax, max(max(t1, t2), tmin));
出於同樣的原因,最好像這樣展開循環,以避免處理不必要的更多無窮大:
bool intersection(box b, ray r) {
double t1 = (b.min[0] - r.origin[0])*r.dir_inv[0];
double t2 = (b.max[0] - r.origin[0])*r.dir_inv[0];
double tmin = min(t1, t2);
double tmax = max(t1, t2);
for (int i = 1; i < 3; ++i) {
t1 = (b.min[i] - r.origin[i])*r.dir_inv[i];
t2 = (b.max[i] - r.origin[i])*r.dir_inv[i];
tmin = max(tmin, min(min(t1, t2), tmax));
tmax = min(tmax, max(max(t1, t2), tmin));
}
return tmax > max(tmin, 0.0);
}
很難理解爲什麼這個版本是正確的:x座標的任何NaN都會傳播到末端,而其他座標的NaN將導致tmin≥tmax。 在兩種情況下,都返回false。
使用-O3的GCC 4.9.2,該實現每秒處理超過9300萬條射線,這意味着即使沒有向量化,運行時間也大約是30個時鐘週期!
v2.3 更好的解決方案
可悲的是,這仍然比沒有顯式NaN處理的版本慢15%。 而且由於遍歷有界體積層次結構時通常使用此算法,因此最糟糕的事情是,在退化情況下遍歷過多的節點。 對於許多應用程序而言,這是非常值得的,並且如果正確實現射線/物體相交功能,則在實踐中絕不應導致任何視覺僞影。 爲了完整起見,這是一個快速實施(1.08億射線/秒),它不會嘗試一致地處理NaN:
bool intersection(box b, ray r) {
double t1 = (b.min[0] - r.origin[0])*r.dir_inv[0];
double t2 = (b.max[0] - r.origin[0])*r.dir_inv[0];
double tmin = min(t1, t2);
double tmax = max(t1, t2);
for (int i = 1; i < 3; ++i) {
t1 = (b.min[i] - r.origin[i])*r.dir_inv[i];
t2 = (b.max[i] - r.origin[i])*r.dir_inv[i];
tmin = max(tmin, min(t1, t2));
tmax = min(tmax, max(t1, t2));
}
return tmax > max(tmin, 0.0);
}
在[6]中給出了我用來測試各種cross()實現的程序。 在有關該主題的下一篇文章中,我將討論低級實現的細節,包括矢量化,以從該算法中獲得最大的性能。
[1]: Brian Smits: Efficiency Issues for Ray Tracing. Journal of Graphics Tools (1998).
[2]: Amy Williams. et al.: An Efficient and Robust Ray-Box Intersection Algorithm. Journal of Graphics Tools (2005).
[3]: https://groups.google.com/forum/#!topic/llvm-dev/-SKl0nOJW_w
[4]: https://ghc.haskell.org/trac/ghc/ticket/9251
[5]: http://www.flipcode.com/archives/SSE_RayBox_Intersection_Test.shtml
[6]: https://gist.github.com/tavianator/132d081ed4d410c755fd
相關/參考鏈接
- FAST, BRANCHLESS RAY/BOUNDING BOX INTERSECTIONS | 有動態圖,講解了Kay, T. L. and Kajiya等人提出的slab方法。展示了從基本實現到一個比較好的實現的過程,代碼無分支結構,適用於並行處理器。
- FAST, BRANCHLESS RAY/BOUNDING BOX INTERSECTIONS, PART 2: NANS | 上一篇文章的改進,上一版中代碼已基本可用,但是在射線與平板重合的時候失效,這時候會存再NaN值,本文討論並高效的解決了這個問題。