Google S2 中的 CellID 是如何生成的 ?

一縷殤流化隱半邊冰霜發表於2019-02-22

筆者在《高效的多維空間點索引演算法 — Geohash 和 Google S2》文章中詳細的分析了 Google S2 的演算法實現思想。文章發出來以後,一部分讀者對它的實現產生了好奇。本文算是對上篇文章的補充,將從程式碼實現的角度來看看 Google S2 的演算法具體實現。建議先讀完上篇文章裡面的演算法思想,再看本篇的程式碼實現會更好理解一些。

一. S(lat,lng) -> f(x,y,z)

第一步轉換,將球面座標轉換成三維直角座標


func makeCell() {
    latlng := s2.LatLngFromDegrees(30.64964508, 104.12343895)
    cellID := s2.CellIDFromLatLng(latlng)
}複製程式碼

上面短短兩句話就構造了一個 64 位的CellID。


func LatLngFromDegrees(lat, lng float64) LatLng {
    return LatLng{s1.Angle(lat) * s1.Degree, s1.Angle(lng) * s1.Degree}
}複製程式碼

上面這一步是把經緯度轉換成弧度。由於經緯度是角度,弧度轉角度乘以 π / 180° 即可。



const (
    Radian Angle = 1
    Degree       = (math.Pi / 180) * Radian
}複製程式碼

LatLngFromDegrees 就是把經緯度轉換成 LatLng 結構體。LatLng 結構體定義如下:


type LatLng struct {
    Lat, Lng s1.Angle
}複製程式碼

得到了 LatLng 結構體以後,就可以通過 CellIDFromLatLng 方法把經緯度弧度轉成 64 位的 CellID 了。


func CellIDFromLatLng(ll LatLng) CellID {
    return cellIDFromPoint(PointFromLatLng(ll))
}複製程式碼

上述方法也分了2步完成,先把經緯度轉換成座標系上的一個點,再把座標系上的這個點轉換成 CellID。

關於經緯度如何轉換成座標系上的一個點,這部分的大體思路分析見筆者的這篇文章,這篇文章告訴你從程式碼實現的角度如何把球面座標系上的一個點轉換到四叉樹上對應的希爾伯特曲線點。



func PointFromLatLng(ll LatLng) Point {
    phi := ll.Lat.Radians()
    theta := ll.Lng.Radians()
    cosphi := math.Cos(phi)
    return Point{r3.Vector{math.Cos(theta) * cosphi, math.Sin(theta) * cosphi, math.Sin(phi)}}
}複製程式碼

上面這個函式就是把經緯度轉換成三維座標系中的一個向量點,向量的起點是三維座標的原點,終點為球面上轉換過來的點。轉換的關係如下圖:

θ 即為經緯度的緯度,也就是上面程式碼中的 phi ,φ 即為經緯度的經度,也就是上面程式碼的 theta 。根據三角函式就可以得到這個向量的三維座標:


x = r * cos θ * cos φ
y = r * cos θ * sin φ 
z = r * sin θ複製程式碼

圖中球面的半徑 r = 1 。所以最終構造出來的向量即為:


r3.Vector{math.Cos(theta) * cosphi, math.Sin(theta) * cosphi, math.Sin(phi)}複製程式碼

至此,已經完成了球面上的點S(lat,lng) -> f(x,y,z) 的轉換。

二. f(x,y,z) -> g(face,u,v)

接下來進行 f(x,y,z) -> g(face,u,v) 的轉換


func xyzToFaceUV(r r3.Vector) (f int, u, v float64) {
    f = face(r)
    u, v = validFaceXYZToUV(f, r)
    return f, u, v
}複製程式碼

這裡的思路是進行投影。

先從 x,y,z 三個軸上選擇一個最長的軸,作為主軸。


func (v Vector) LargestComponent() Axis {
    t := v.Abs()

    if t.X > t.Y {
        if t.X > t.Z {
            return XAxis
        }
        return ZAxis
    }
    if t.Y > t.Z {
        return YAxis
    }
    return ZAxis
}複製程式碼

預設定義 x 軸為0,y軸為1,z軸為2 。


const (
    XAxis Axis = iota
    YAxis
    ZAxis
)複製程式碼

最後 face 的值就是三個軸裡面最長的軸,注意這裡限定了他們三者都在 [0,5] 之間,所以如果是負軸就需要 + 3 進行修正。實現程式碼如下。



func face(r r3.Vector) int {
    f := r.LargestComponent()
    switch {
    case f == r3.XAxis && r.X < 0:
        f += 3
    case f == r3.YAxis && r.Y < 0:
        f += 3
    case f == r3.ZAxis && r.Z < 0:
        f += 3
    }
    return int(f)
}複製程式碼

所以 face 的6個面上的值就確定下來了。主軸為 x 正半軸,face = 0;主軸為 y 正半軸,face = 1;主軸為 z 正半軸,face = 2;主軸為 x 負半軸,face = 3;主軸為 y 負半軸,face = 4;主軸為 z 負半軸,face = 5 。

選定主軸以後就要把另外2個軸上的座標點投影到這個面上,具體做法就是投影或者座標系轉換。


func validFaceXYZToUV(face int, r r3.Vector) (float64, float64) {
    switch face {
    case 0:
        return r.Y / r.X, r.Z / r.X
    case 1:
        return -r.X / r.Y, r.Z / r.Y
    case 2:
        return -r.X / r.Z, -r.Y / r.Z
    case 3:
        return r.Z / r.X, r.Y / r.X
    case 4:
        return r.Z / r.Y, -r.X / r.Y
    }
    return -r.Y / r.Z, -r.X / r.Z
}複製程式碼

上述就是 face 6個面上的座標系轉換。如果直觀的對應一個外切立方體的哪6個面,那就是 face = 0 對應的是前面,face = 1 對應的是右面,face = 2 對應的是上面,face = 3 對應的是後面,face = 4 對應的是左面,face = 5 對應的是下面。

注意這裡的三維座標軸是符合右手座標系的。即 右手4個手指沿著從 x 軸旋轉到 y 軸的方向,大拇指的指向就是另外一個面的正方向。

比如立方體的前面,右手從 y 軸的正方向旋轉到 z 軸的正方向,大拇指指向的是 x 軸的正方向,所以對應的就是前面。再舉個例子,立方體的下面?,右手從 y 軸的負方向旋轉到 x 軸的負方向,大拇指指向的是 z 軸負方向,所以對應的是下面?。

三. g(face,u,v) -> h(face,s,t)

從 u、v 轉換到 s、t 用的是二次變換。在 C ++ 的版本中有三種變換,至於為何最後選了這種二次變換,原因見這裡



// 線性轉換
u = 0.5 * ( u + 1)

// tan() 變換
u = 2 / pi * (atan(u) + pi / 4) = 2 * atan(u) / pi + 0.5

// 二次變換
u >= 0,u = 0.5 * sqrt(1 + 3*u)
u < 0,    u = 1 - 0.5 * sqrt(1 - 3*u)複製程式碼

在 Go 中,轉換直接就只有二次變換了,其他兩種變換在 Go 的實現版本中就直接沒有相應的程式碼。


func uvToST(u float64) float64 {
    if u >= 0 {
        return 0.5 * math.Sqrt(1+3*u)
    }
    return 1 - 0.5*math.Sqrt(1-3*u)
}複製程式碼

四. h(face,s,t) -> H(face,i,j)

這一部分是座標系的轉換,具體思想見這裡

將 s、t 上的點轉換成座標系 i、j 上的點。



func stToIJ(s float64) int {
    return clamp(int(math.Floor(maxSize*s)), 0, maxSize-1)
}複製程式碼

s,t的值域是[0,1],現在值域要擴大到[0,2^30^-1]。這裡只是其中一個面。

五. H(face,i,j) -> CellID

在進行最後的轉換之前,先回顧一下到目前為止的轉換流程。



func CellIDFromLatLng(ll LatLng) CellID {
    return cellIDFromPoint(PointFromLatLng(ll))
}

func cellIDFromPoint(p Point) CellID {
    f, u, v := xyzToFaceUV(r3.Vector{p.X, p.Y, p.Z})
    i := stToIJ(uvToST(u))
    j := stToIJ(uvToST(v))
    return cellIDFromFaceIJ(f, i, j)
}複製程式碼

S(lat,lng) -> f(x,y,z) -> g(face,u,v) -> h(face,s,t) -> H(face,i,j) -> CellID 總共有5步轉換。

在解釋最後一步轉換 CellID 之前,先說明一下方向的問題。

有2個存了常量的陣列:



    ijToPos = [4][4]int{
        {0, 1, 3, 2}, // canonical order
        {0, 3, 1, 2}, // axes swapped
        {2, 3, 1, 0}, // bits inverted
        {2, 1, 3, 0}, // swapped & inverted
    }
    posToIJ = [4][4]int{
        {0, 1, 3, 2}, // canonical order:    (0,0), (0,1), (1,1), (1,0)
        {0, 2, 3, 1}, // axes swapped:       (0,0), (1,0), (1,1), (0,1)
        {3, 2, 0, 1}, // bits inverted:      (1,1), (1,0), (0,0), (0,1)
        {3, 1, 0, 2}, // swapped & inverted: (1,1), (0,1), (0,0), (1,0)
    }複製程式碼

這兩個二維陣列裡面的值用圖表示出來如下兩個圖:

上圖是 posToIJ ,注意這裡的 i,j 指的是座標值,如上圖。這裡是一階的希爾伯特曲線,所以 i,j 就等於座標軸上的值。posToIJ[0] = {0, 1, 3, 2} 表示的就是上圖中圖0的樣子。同理,posToIJ[1] 表示的是圖1,posToIJ[2] 表示的是圖2,posToIJ[3] 表示的是圖3 。

從上面這四張圖我們可以看出:
posToIJ 的四張圖其實是“ U ” 字形逆時針分別旋轉90°得到的。這裡我們只能看出四張圖相互之間的聯絡,即兄弟之間的聯絡,但是看不到父子圖相互之間的聯絡。

posToIJ[0] = {0, 1, 3, 2} 裡面存的值是 ij 合在一起表示的值。posToIJ[0][0] = 0,指的是 i = 0,j = 0 的那個方格,ij 合在一起是00,即0。posToIJ[0][1] = 1,指的是 i = 0,j = 1 的那個方格,ij 合在一起是01,即1。posToIJ[0][2] = 1,指的是 i = 1,j = 1 的那個方格,ij 合在一起是11,即3。posToIJ[0][3] = 2,指的是 i = 1,j = 0 的那個方格,ij 合在一起是10,即2。陣列裡面的順序是 “ U ” 字形畫的順序。所以 posToIJ[0] = {0, 1, 3, 2} 表示的是圖0中的樣子。其他圖形同理。

這上面的四張圖是 ijToPos 陣列。這個陣列在整個庫中也沒有被用到,這裡不用關係它對應的關係。

初始化 lookupPos 陣列和 lookupIJ 陣列 由如下的程式碼實現的。


func init() {
    initLookupCell(0, 0, 0, 0, 0, 0)
    initLookupCell(0, 0, 0, swapMask, 0, swapMask)
    initLookupCell(0, 0, 0, invertMask, 0, invertMask)
    initLookupCell(0, 0, 0, swapMask|invertMask, 0, swapMask|invertMask)
}複製程式碼

我們把變數的值都代進去,程式碼就會變成下面的樣子:


func init() {
    initLookupCell(0, 0, 0, 0, 0, 0)
    initLookupCell(0, 0, 0, 1, 0, 1)
    initLookupCell(0, 0, 0, 2, 0, 2)
    initLookupCell(0, 0, 0, 3, 0, 3)
}複製程式碼

initLookupCell 入參有6個引數,有4個引數都是0,我們需要重點關注的是第四個引數和第六個引數。第四個引數是 origOrientation,第六個引數是 orientation。

進入到 initLookupCell 方法中,有如下的4行:


initLookupCell(level, i+(r[0]>>1), j+(r[0]&1), origOrientation, pos, orientation^posToOrientation[0])
initLookupCell(level, i+(r[1]>>1), j+(r[1]&1), origOrientation, pos+1, orientation^posToOrientation[1])
initLookupCell(level, i+(r[2]>>1), j+(r[2]&1), origOrientation, pos+2, orientation^posToOrientation[2])
initLookupCell(level, i+(r[3]>>1), j+(r[3]&1), origOrientation, pos+3, orientation^posToOrientation[3])複製程式碼

這裡順帶說一下 r[0]>>1 和 r[0]&1 究竟做了什麼。


    r := posToIJ[orientation]複製程式碼

r 陣列來自於 posToIJ 陣列。posToIJ 陣列上面說過了,它裡面裝的其實是4個不同方向的“ U ”字。相當於表示了當前四個小方格兄弟相互之間的方向。r[0]、r[1]、r[2]、r[3] 取出的其實就是 00,01,10,11 這4個數。那麼 r[0]>>1 操作就是取出二位二進位制位的前一位,即 i 位。r[0]&1 操作就是取出二位二進位制位的後一位,即 j 位。r[1]、r[2]、r[3] 同理。

再回到方向的問題上來。需要優先說明的是下面4行幹了什麼。


orientation^posToOrientation[0]
orientation^posToOrientation[1]
orientation^posToOrientation[2]
orientation^posToOrientation[3]複製程式碼

再解釋之前,先讓我們看看 posToOrientation 陣列:


posToOrientation = [4]int{swapMask, 0, 0, invertMask | swapMask}複製程式碼

把數值代入到上面陣列中:


posToOrientation = [4]int{1, 0, 0, 3}複製程式碼

posToOrientation 陣列裡面裝的原始的值是 [01,00,00,11],這個4個數值並不是隨便初始化的。

其實這個對應的就是 圖0 中4個小方塊接下來再劃分的方向。圖0 中0號的位置下一個圖的方向應該是圖1,即01;圖0 中1號的位置下一個圖的方向應該是圖0,即00;圖0 中2號的位置下一個圖的方向應該是圖0,即00;圖0 中3號的位置下一個圖的方向應該是圖3,即11 。這就是初始化 posToOrientation 陣列裡面的玄機了。

posToIJ 的四張圖我們只能看出兄弟之間的關係,那麼 posToOrientation 的四張圖讓我們知道了父子之間的關係。

回到上面說的程式碼:


orientation^posToOrientation[0]
orientation^posToOrientation[1]
orientation^posToOrientation[2]
orientation^posToOrientation[3]複製程式碼

每次 orientation 都異或 posToOrientation 陣列。這樣就能保證每次都能根據上一次的原始的方向推算出當前的 pos 所在的方向。即計算父子之間關係。

還是回到這張圖上來。兄弟之間的關係是逆時針旋轉90°的關係。那這4個兄弟都作為父親,分別和各自的4個孩子之間什麼關係呢?結論是,父子之間的關係都是 01,00,00,11 的關係。從圖上我們也可以看出這一點,圖1中,“ U ” 字形雖然逆時針旋轉了90°,但是它們的孩子也跟著旋轉了90°(相對於圖0來說)。圖2,圖3也都如此。

用程式碼表示這種關係,就是下面這4行程式碼


orientation^posToOrientation[0]
orientation^posToOrientation[1]
orientation^posToOrientation[2]
orientation^posToOrientation[3]複製程式碼

舉個例子,假設 orientation = 0,即圖0,那麼:


00 ^ 01 = 01
00 ^ 00 = 00
00 ^ 00 = 00
00 ^ 11 = 11複製程式碼

圖0 的四個孩子的方向就被我們算出來了,01,00,00,11,1003 。和上面圖片中圖0展示的是一致的。

orientation = 1,orientation = 2,orientation = 3,都是同理的:


01 ^ 01 = 00
01 ^ 00 = 01
01 ^ 00 = 01
01 ^ 11 = 10


10 ^ 01 = 11
10 ^ 00 = 10
10 ^ 00 = 10
10 ^ 11 = 01

11 ^ 01 = 10
11 ^ 00 = 11
11 ^ 00 = 11
11 ^ 11 = 00複製程式碼

圖1孩子的方向是0,1,1,2 。圖2孩子的方向是3,2,2,1 。圖3孩子的方向是2,3,3,0 。和圖上畫的是完全一致的。

所以上面的轉換是很關鍵的。這裡就是針對希爾伯特曲線的父子方向進行換算的。

最後會有讀者有疑問,origOrientation 和 orientation 是啥關係?


lookupPos[(ij<<2)+origOrientation] = (pos << 2) + orientation
lookupIJ[(pos<<2)+origOrientation] = (ij << 2) + orientation複製程式碼

陣列下標裡面存的都是 origOrientation,下標裡面存的值都是 orientation。

解釋完希爾伯特曲線方向的問題之後,接下來可以再仔細說說 55 的座標轉換的問題。前一篇文章《高效的多維空間點索引演算法 — Geohash 和 Google S2》裡面有談到這個問題,讀者有些疑惑點,這裡再最終解釋一遍。

在 Google S2 中,初始化 initLookupCell 的時候,會初始化2個陣列,一個是 lookupPos 陣列,一個是 lookupIJ 陣列。中間還會用到 i , j , pos 和 orientation 四個關鍵的變數。orientation 這個之前說過了,這裡就不再贅述了。需要詳細說明的 i ,j 和 pos 的關係。

pos 指的是在 希爾伯特曲線上的位置。這個位置是從 希爾伯特 曲線的起點開始算的。從起點開始數,到當前是第幾塊方塊。注意這個方塊是由 4 個小方塊組成的大方塊。因為 orientation 是選擇4個方塊中的哪一個。

在 55 的這個例子裡,pos 其實是等於 13 的。代表當前4塊小方塊組成的大方塊是距離起點的第13塊大方塊。由於每個大方塊是由4個小方塊組成的。所以當前這個大方塊的第一個數字是 13 * 4 = 52 。程式碼實現就是左移2位,等價於乘以 4 。再加上 55 的偏移的 orientation = 11,再加 3 ,所以 52 + 3 = 55 。

再說說 i 和 j 的問題,在 55 的這個例子裡面 i = 14,1110,j = 13,1101 。如果直觀的看座標系,其實 55 是在 (5,2) 的座標上。但是現在為何 i = 14,j = 13 呢 ?這裡容易弄混的就是 i ,j 和 pos 的關係。

注意:
i,j 並不是直接對應的 希爾伯特曲線 座標系上的座標。因為初始化需要生成的是五階希爾伯特曲線。在 posToIJ 陣列表示的一階希爾伯特曲線,所以 i,j 才直接對應的 希爾伯特曲線 座標系上的座標。

讀者到這裡就會疑問了,那是什麼引數對應的是希爾伯特曲線座標系上的座標呢?

pos 引數對應的就是希爾伯特曲線座標系上的座標。一旦一個希爾伯特曲線的起始點和階數確定以後,四個小方塊組成的一個大方塊的 pos 位置確定以後,那麼它的座標其實就已經確定了。希爾伯特曲線上的座標並不依賴 i,j,完全是由曲線的性質和 pos 位置決定的。

我們並不關心希爾伯特曲線上小方塊的座標,我們關心的是 pos 和 i,j 的轉換關係!

疑問又來了,那 i,j 對應的是什麼座標系上的座標呢?

i,j 對應的是一個經過座標變換以後的座標系座標。

我們知道,在進行 ( u,v ) -> ( i,j ) 變換的時候,u,v 的值域是 [0,1] 之間,然後經過變換要變到 [ 0, 2^30^-1 ] 之間。i,j 就是變換以後座標系上的座標值,i,j 的值域變成了 [ 0, 2^30^-1 ] 。

那初始化計算 lookupPos 陣列和 lookupIJ 陣列有什麼用呢?這兩個陣列就是把 i,j 和 pos 聯絡起來的陣列。知道 pos 以後可以立即找到對應的 i,j。知道 i,j 以後可以立即找到對應的 pos。

i,j 和 pos 互相轉換之間的橋樑就是生成希爾伯特曲線的方式。這種方式可以類比 Z - index 曲線的生成方式。

Z - index 曲線的生成方式是把經緯度座標分別進行區間二分,在左區間的記為0,在右區間的記為1 。將這兩串二進位制字串偶數位放經度,奇數位放緯度,最終組合成新的二進位制串,這個串再經過 base-32 編碼以後,最終就生成了 geohash 。

那麼 希爾伯特 曲線的生成方式是什麼呢?它先將經緯度座標轉換成了三維直角座標系座標,然後再投影到外切立方體的6個面上,於是三維直角座標系座標 (x,y,z) 就轉換成了 (face,u,v) 。 (face,u,v) 經過一個二次變換變成 (face,s,t) , (face,s,t) 經過座標系變換變成了 (face,i,j) 。然後將 i,j 分別4位4位的取出來,i 的4位二進位制位放前面,j 的4位二進位制位放後面。最後再加上希爾伯特曲線的方向位 orientation 的2位。組成 iiii jjjj oo 類似這樣的10位二進位制位。通過 lookupPos 陣列這個橋樑,找到對應的 pos 的值。pos 的值就是對應希爾伯特曲線上的位置。然後依次類推,再取出 i 的4位,j 的4位進行這樣的轉換,直到所有的 i 和 j 的二進位制都取完了,最後把這些生成的 pos 值安全先生成的放在高位,後生成的放在低位的方式拼接成最終的 CellID。

在 Google S2 中,i,j 每次轉換都是4位,所以 i,j 的有效值取值是 0 - 15,所以 iiii jjjj oo 是一個十進位制的數,能表示的範圍是 2^10^ = 1024 。那麼 pos 初始化值也需要計算到 1024 。由於 pos 是4個小方塊組成的大方塊,它本身就是一個一階的希爾伯特曲線。所以初始化需要生成一個五階的希爾伯特曲線。

上圖是一階的希爾伯特曲線。是由4個小方格組成的。

上圖是二階的希爾伯特曲線,是由4個 pos 方格組成的。

上圖是三階的希爾伯特曲線。

上圖是四階的希爾伯特曲線。

上圖是五階的希爾伯特曲線。pos 方格總共有1024個。

至此已經說清楚了希爾伯特曲線的方向和在 Google S2 中生成希爾伯特曲線的階數,五階希爾伯特曲線。

由此也可以看出,希爾伯特曲線的是由 “ U ” 字形構成的,由4個不同方向的 “ U ” 字構成。初始方向是開口朝上的 “ U ”。

關於希爾伯特曲線生成的動畫,見上篇《高效的多維空間點索引演算法 — Geohash 和 Google S2》—— 希爾伯特曲線的構造方法 這一章節。

那麼現在我們再推算55就比較簡單了。從五階希爾伯特曲線開始推,推算過程如下圖。

首先55是在上圖中每個小圖中綠色點的位置。我們不斷的進行方向的判斷。第一張小圖,綠點在00的位置。第二張小圖,綠點在00的位置。第三張小圖,綠點在11的位置。第四張小圖,綠點在01的位置。第五張小圖,綠點在11的位置。其實換算到第四步,得到的數值就是 pos 的值,即 00001101 = 13 。最後2位是具體的點在 pos 方格里面的位置,是11,所以 13 * 4 + 3 = 55 。

當然直接根據方向推算到底,也可以得到 0000110111 = 55 ,同樣也是55 。

最後舉個具體的完整的例子:

緯度 經度
直角座標系 -0.209923466239598816018841 0.834295703289209877873134 0.509787031803590306999752
(face,u,v) 1 0.25161758044776666 0.6110387837235114
(face,s,t) 1 0.6623542747924445 0.8415931842598497
(face,i,j) 1 711197487 903653800

上面完成了前4步的轉換。

最後一步轉換成 CellID 。具體實現程式碼如下。由於 CellID 是64位的,除去 face 佔的3位,最後一個標誌位 1 佔的位置,剩下 60 位。


func cellIDFromFaceIJ(f, i, j int) CellID {
  // 1.
    n := uint64(f) << (posBits - 1)
  // 2.
    bits := f & swapMask
  // 3.
    for k := 7; k >= 0; k-- {
        mask := (1 << lookupBits) - 1
        bits += int((i>>uint(k*lookupBits))&mask) << (lookupBits + 2)
        bits += int((j>>uint(k*lookupBits))&mask) << 2
        bits = lookupPos[bits]
    // 4.
        n |= uint64(bits>>2) << (uint(k) * 2 * lookupBits)
    // 5.
        bits &= (swapMask | invertMask)
    }
  // 6.
    return CellID(n*2 + 1)
}複製程式碼

具體步驟如下:

  1. 將 face 左移 60 位。
  2. 計算初始的 origOrientation
  3. 迴圈,從頭開始依次取出 i ,j 的4位二進位制位,計算出 ij<<2 + origOrientation,然後查 lookupPos 陣列找到對應的 pos<<2 + orientation 。
  4. 拼接 CellID,右移 pos<<2 + orientation 2位,只留下 pos ,把pos 繼續拼接到 上次迴圈的 CellID 後面。
  5. 計算下一個迴圈的 origOrientation。&= (swapMask | invertMask) 即 & 11,也就是取出末尾的2位二進位制位。
  6. 最後拼接上最後一個標誌位 1 。

用表展示出每一步(表比較長,請右滑):

i j orientation ij<<2 + origOrientation pos<<2 + orientation CellID
711197487 903653800 1
對應二進位制 101010011001000000001100101111 110101110111001010100110101000 01
進行轉換 i 左移6位,給 j 的4位和方向位 orientation 2位留出位置 j 左移2位,給方向位 orientation 留出位置 orientation 初始值是 face 的值 [iiii jjjj oo] i的四位,j的四位,o的兩位依次排在一起組成10位二進位制位 從前面一列轉換過來是通過查 lookupPos 陣列查出來的 初始值:face 左移 60 位,接著以後每次迴圈都拼接 pos ,注意不帶orientation ,即前一列需要右移2位去掉末尾的 orientation
取 i , j 的首兩位 10 000000 11 00 01 (00)10001101 101110 1101100000000000000000000000000000000000000000000000000000000
再取 i , j 的3,4,5,6位 1010 000000 0101 00 10 1010010110 111011110 1101101110111000000000000000000000000000000000000000000000000
再取 i , j 的7,8,9,10位 0110 000000 1101 00 10 (0)110110110 1110011110 1101101110111111001110000000000000000000000000000000000000000
再取 i , j 的11,12,13,14位 0100 000000 1100 00 10 (0)100110010 1110000001 1101101110111111001111110000000000000000000000000000000000000
再取 i , j 的15,16,17,18位 0000 000000 1010 00 01 (0000)101001 1110110000 1101101110111111001111110000011101100000000000000000000000000
再取 i , j 的19,20,21,22位 0011 000000 1001 00 00 (00)11100100 100011001 1101101110111111001111110000011101100010001100000000000000000
再取 i , j 的23,24,25,26位 0010 000000 1010 00 01 (00)10101001 1110001011 1101101110111111001111110000011101100010001101110001000000000
再取 i , j 的27,28,29,30位 1111 000000 1000 00 11 1111100011 1010110 1101101110111111001111110000011101100010001101110001000010101
最終結果 11011011101111110011111100000111011000100011011100010000101011
(拼接上末尾的標誌位1)

任意取出迴圈中的一個情況,用圖表示如下:

注意:由於 CellID 是64位的,頭三位是 face ,末尾一位是標誌位,所以中間有 60 位。i,j 轉換成二進位制是30位的。7個4位二進位制位和1個2位二進位制位。4*7 + 2 = 30 。iijjoo ,即 i 的頭2個二進位制位和 j 的頭2個二進位制位加上 origOrientation,這樣組成的是6位二進位制位,最多能表示 2^6^ = 32,轉換出來的 pos + orientation 最多也是32位的。即轉換出來最多也是6位的二進位制位,除去末尾2位 orientation ,所以 pos 在這種情況下最多是 4位。iiiijjjjpppp,即 i 的4個二進位制位和 j 的4個二進位制位加上 origOrientation,這樣組成的是10位二進位制位,最多能表示 2^10^ = 1024,轉換出來的 pos + orientation 最多也是10位的。即轉換出來最多也是10位的二進位制位,除去末尾2位 orientation ,所以 pos 在這種情況下最多是 8位。

由於最後 CellID 只拼接 pos ,所以 4 + 7 * 8 = 60 位。拼接完成以後,中間的60位都由 pos 組成的。最後拼上頭3位,末尾的1位標誌位,64位的 CellID 就這樣生成了。

到此,所有的 CellID 生成過程就結束了。


空間搜尋系列文章:

如何理解 n 維空間和 n 維時空
高效的多維空間點索引演算法 — Geohash 和 Google S2
Google S2 中的 CellID 是如何生成的 ?
Google S2 中的四叉樹求 LCA 最近公共祖先
神奇的德布魯因序列

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: halfrost.com/go_s2_cellI…

相關文章