Web 魔方模擬器的設計與實現

doodlewind發表於2018-08-27

魔方是個結構簡單而變化無窮的神奇玩具。那麼如何在萬能的瀏覽器裡模擬出魔方的無盡變換,又如何將其還原呢?下面讓我們一步步地來一探究竟吧。

魔方的抽象

拆解過魔方的同學可能知道,現實中魔方的內部結構包含了中軸、彈簧、螺絲等機械裝置。但當我們只是想要「模擬」它的時候,我們只需抓住它最顯著的性質即可——3x3x3 的一組立方體:

cube-render-loop

基本概念

上圖演示了魔方最基本的思維模型。但光有這樣的感性認識還不夠:組成魔方的每個塊並非隨意安置,它們之間有著細微的區別:

  • 位於魔方各角的塊稱為角塊,每個角塊均具有 3 個顏色。一個立方體有 8 個角,故而一個魔方也具有 8 個角塊。
  • 位於魔方各稜上的塊稱為稜塊,每個稜塊均具有 2 個顏色。一個立方體有 12 條稜,故而一個魔方也具有 12 個稜塊。
  • 位於魔方各面中心的塊稱為中心塊,每個中心塊僅有 1 個顏色。一個立方體有 6 個面,故而一個魔方也具有 6 箇中心塊。
  • 位於整個魔方中心的塊沒有顏色,在渲染和還原的過程中也不起到什麼實際的用處,我們可以忽略這個塊。

將以上四種塊的數量相加,正好是 3^3 = 27 塊。對這些塊,你所能使用的唯一操作(或者說變換)方式,就是在不同面上的旋轉。那麼,我們該如何標識出一次旋轉操作呢?

設想你的手裡「端正地」拿著一個魔方,我們將此時面對你的那一面定義為 Front,背對的一面定義為 Back。類似地,我們有了 Left / Right / Upper / Down 來標識其餘各面。當你旋轉某一面時,我們用這一面的簡寫(F / B / L / R / U / D)來標識在這一面上的一次順時針 90 度旋轉。對於一次逆時針的旋轉,我們則用 F' / U' 這樣帶 ' 的記號來表達。如果你旋轉了 180 度,那麼可以用形如 R2 / U2 的方式表示。如下圖的 5 次操作,如果我們約定藍色一面為 Front,其旋轉序列就是 F' R' L' B' F'

cube-solve

關於魔方的基礎結構和變換方式,知道這些就足夠了。下面我們需要考慮這個問題:如何設計一個資料結構來儲存的魔方狀態,並使用程式語言來實現某個旋轉變換呢?

資料結構

喜歡基於「物件導向」抽象的同學可能很快就能想到,我們可以為每個塊設計一個 Block 基類,然後用形如 CornerBlockEdgeBlock 的類來抽象稜塊和角塊,在每個角塊例項中還可以儲存這個角塊到它相鄰三個稜塊的引用……這樣一個魔方的 Cube 物件只需持有對中心塊的引用,就可以基於各塊例項的鄰接屬性儲存整個魔方了。

上面這種實現很類似於連結串列,它可以 O(1) 地實現「給定某個塊,查詢其鄰接塊」的操作,但不難發現,它需要 O(N) 的複雜度來實現形如「某個位置的塊在哪裡」這樣的查詢操作,基於它的旋轉操作也並不十分符合直覺。相對地,另一種顯得「過於暴力」的方式反而相當實用:直接開闢一個長度為 27 的陣列,在其中儲存每一塊的顏色資訊即可。

為什麼可以這樣呢?我們知道,陣列在基於下標訪問時,具有 O(1) 的時間複雜度。而如果我們在一個三維座標系中定位魔方的每一個塊,那麼每個塊的空間座標都可以唯一地對映到陣列的下標上。更進一步地,我們可以令 x, y, z 分別取 -1, 0, 1 這三個值來表達一個塊在其方向上可能的位置,這時,例如前面所定義的一次 U 旋轉,剛好就是對所有 y 軸座標值為 1 的塊的旋轉。這個良好的性質很有利於實現對魔方的變換操作。

旋轉變換

在約定好資料結構之後,我們如何實現對魔方的一次旋轉變換呢?可能有些同學會直接將這個操作與三維空間中的四階變換矩陣聯絡起來。但只要注意到一次旋轉的角度都是 90 度的整數倍,我們可以利用數學性質極大地簡化這一操作:

在旋轉 90 度時,旋轉面上每個角塊都旋轉到了該面上的「下一個」角塊的位置上,稜塊也是這樣。故而,我們只需要迴圈交替地在每個塊的「下一個」位置賦值,就能輕鬆地將塊「移動」到其新位置上。但這還不夠:每個新位置上的塊,還需要對其自身六個面的顏色做一次「自旋」,才能將它的朝向指向正確的位置。這也是一次交替的賦值操作。從而,一次三維空間繞某個面中心的旋轉操作,就被我們分解為了一次平移操作和一次繞各塊中心的旋轉操作。只需要 30 餘行程式碼,我們就能實現這一魔方最核心的變換機制:

rotate (center, clockwise = true) {
  const axis = center.indexOf(1) + center.indexOf(-1) + 1
  // Fix y direction in right-handed coordinate system.
  clockwise = center[1] !== 0 ? !clockwise : clockwise
  // Fix directions whose faces are opposite to axis.
  clockwise = center[axis] === 1 ? clockwise : !clockwise

  let cs = [[1, 1], [1, -1], [-1, -1], [-1, 1]] // corner coords
  let es = [[0, 1], [1, 0], [0, -1], [-1, 0]] // edge coords
  const prepareCoord = coord => coord.splice(axis, 0, center[axis])
  cs.forEach(prepareCoord); es.forEach(prepareCoord)
  if (!clockwise) { cs = cs.reverse(); es = es.reverse() }

  // 移動每個塊到其新位置
  const rotateBlocks = ([a, b, c, d]) => {
    const set = (a, b) => { for (let i = 0; i < 6; i++) a[i] = b[i] }
    const tmp = []; set(tmp, a); set(a, d); set(d, c); set(c, b); set(b, tmp)
  }
  const colorsAt = coord => this.getBlock(coord).colors
  rotateBlocks(cs.map(colorsAt)); rotateBlocks(es.map(colorsAt))

  // 調整每個塊的自旋朝向
  const swap = [
    [[F, U, B, D], [L, F, R, B], [L, U, R, D]],
    [[F, D, B, U], [F, L, B, R], [D, R, U, L]]
  ][clockwise ? 0 : 1][axis]
  const rotateFaces = coord => {
    const block = colorsAt(coord)
    ;[block[swap[1]], block[swap[2]], block[swap[3]], block[swap[0]]] =
    [block[swap[0]], block[swap[1]], block[swap[2]], block[swap[3]]]
  }
  cs.forEach(rotateFaces); es.forEach(rotateFaces)
  return this
}
複製程式碼

這個實現的效率應該不差:在筆者的瀏覽器裡,上面的程式碼可以支援每秒 30 萬次的旋轉變換。為什麼在這裡我們需要在意效能呢?在魔方的場景下,有一個非常不同的地方,即狀態的有效性與校驗

熟悉魔方的同學應該知道,並不是隨便給每塊塗上不同顏色的魔方都是可以還原的。在普通的業務開發領域,資料的有效性和校驗常常可以通過型別系統來保證。但對於一個打亂的魔方,保證它的可解性則是一個困難的數學問題。故而我們在儲存魔方狀態時,只有儲存從六面同色的初始狀態到當前狀態下的所有變換步驟,才能保證這個狀態一定是可解的。這樣一來,反序列化一個魔方狀態的開銷就與操作步驟數量之間有了 O(N) 的關聯。好在一個實際把玩中的魔方狀態一般只會在 100 步之內,故而上面以犧牲時間複雜度換取資料有效性的代價應當是值得的。另外,這個方式可以非常簡單地實現魔方任意狀態之間的時間旅行:從初始狀態走到任意一步的歷史狀態,都只需要疊加上它們之間一系列的旋轉 diff 操作即可。這是一個很可靠的思維模型。

上面的實現中有一個特別之處:當座標軸是 y 軸時,我們為旋轉方向進行了一次取反操作。這初看起來並不符合直覺,但其背後卻是座標系定義的問題:如果你推導過每個塊在順時針變換時所處的下一個位置,那麼在高中教科書和 WebGL 所用的右手座標系中,繞 y 軸旋轉時各個塊的下一個位置,其交換順序與 x 軸和 z 軸是相反的。反而在 DirectX 的左手座標系中,旋轉操作的正負能完全和座標系的朝向一致。筆者作為區區碼農,並不瞭解這背後的對稱性是否蘊含了什麼深刻的數學原理,希望數學大佬們解惑。

到此為止,我們已經基本完成了對魔方狀態的抽象和變換演算法的設計了。但相信很多同學可能更好奇的是這個問題:在瀏覽器環境下,我們該如何渲染出魔方呢?讓我們來看看吧。

魔方的渲染

在瀏覽器這個以無數的二維矩形作為排版原語的世界裡,要想渲染魔方這樣的三維物體並不是件查個文件寫幾行膠水程式碼就可以搞定的事情。好在我們有 WebGL 這樣的三維圖形庫可供差遣(當然了,相信熟悉樣式的同學應該是可以使用 CSS 來渲染魔方的,可惜筆者的 CSS 水平很菜)。

WebGL 渲染基礎

由於魔方思維模型的簡單性,要渲染它並不需要使用圖形學中紋理、光照和陰影等高階特性,只需要最基本的幾何圖形繪製特性就足夠了。正因為如此,筆者在這裡只使用了完全原生的 WebGL API 來繪製魔方。籠統地說,渲染魔方這樣的一組立方體,所需要的步驟大致如下:

  1. 初始化著色器(編譯供 GPU 執行的程式)
  2. 向緩衝區中傳遞頂點和顏色資料(操作視訊記憶體)
  3. 設定用於觀察的透視矩陣和模-視變換矩陣(傳遞變數給 GPU)
  4. 呼叫 drawElementsdrawArray 渲染一幀

在前文中,我們設計的資料結構使用了長度為 27 的陣列來儲存 [-1, -1, -1][1, 1, 1] 的一系列塊。在一個三重的 for 迴圈裡,逐個將這些塊繪製到螢幕上的邏輯大概就像前面看到的這張圖:

cube-render-loop

需要注意的是,並不是越接近底層的程式碼就一定越快。例如在最早的實現中,筆者直接通過迴圈呼叫來自(或者說抄自)MDN 的 3D 立方體例程來完成 27 個小塊的渲染。這時對於 27 個立方體區區不足千個頂點,60 幀繪製動畫時的 CPU 佔用率都可能跑滿。經過定位,發現重複的 CPU 與 GPU 互動是一個大忌:從 CPU 向 GPU 傳遞資料,以及最終對 GPU 繪圖 API 的呼叫,都具有較大的固定開銷。一般我們需要將一幀中 Draw Call 的數量控制在 20 個以內,對於 27 個立方體就使用 27 次 Draw Call 的做法顯然是個反模式。在將程式碼改造為一次批量傳入全部頂點並呼叫一次 drawElements 後,即可實現流暢的 60 幀動畫了 :)

旋轉動畫實現

在實現基本的渲染機制後,魔方整體的旋轉效果可以通過對模-視矩陣做矩陣乘法來實現。模-視矩陣會在頂點著色器由 GPU 中對每一個頂點並行地計算,得到頂點變換後的 gl_Position 位置。但對於單個面的旋轉,我們選擇了先在 CPU 中計算好頂點位置,再將其傳入頂點緩衝區。這和魔方旋轉動畫的實現原理直接相關:

  • 在一次某個面的旋轉過程中,魔方的資料模型不發生改變,僅改變受影響的頂點所在位置。
  • 在旋轉結束時,我們呼叫上文中實現的 rotate API 來「瞬間旋轉好」魔方的資料模型,而後再多繪製一幀。

我們首先需要設計用於渲染一幀的 render API。考慮到魔方在繪製時可能存在對某個面一定程度的旋轉,這個無狀態的渲染 API 介面形如:

render (rX = 0, rY = 0, moveFace = null, moveAngle = 0) {
  if (!this.gl) throw new Error('Missing WebGL context!')
  this.buffer = getBuffer(this.gl, this.blocks, moveFace, moveAngle)
  renderFrame(this.gl, this.programInfo, this.buffer, rX, rY)
}
複製程式碼

而對單個面的旋轉過程中,我們可以使用瀏覽器的 requestAnimationFrame API 來實現基本的時序控制。一次呼叫 animate 的旋轉返回一個在單次旋轉結束時 resolve 的 Promise,其實現如下:

animate (move = null, duration = 500) {
  if (move && move.length === 0) return Promise.resolve()
  if (!move || this.__ANIMATING) throw new Error('Unable to animate!')

  this.__ANIMATING = true
  let k = move.includes("'") ? 1 : -1
  if (/B|D|L/.test(move)) k = k * -1
  const beginTime = +new Date()
  return new Promise((resolve, reject) => {
    const tick = () => {
      const diff = +new Date() - beginTime
      const percentage = diff / duration
      const face = move.replace("'", '')
      if (percentage < 1) {
        this.render(this.rX, this.rY, face, 90 * percentage * k)
        window.requestAnimationFrame(tick)
      } else {
        this.move(move)
        this.render(this.rX, this.rY, null, 0)
        this.__ANIMATING = false
        resolve()
      }
    }
    window.requestAnimationFrame(tick)
  })
}
複製程式碼

連續旋轉實現

在實現了單次旋轉後,如何支援連續的多次旋轉呢?本著能偷懶就偷懶的想法,筆者對上面的函式進行了不改動已有邏輯的遞迴化改造,只需要在原函式入口處加入如下幾行,就可以使支援傳入陣列為引數來遞迴呼叫自身,並在傳入的連續動畫陣列長度為 1 時作為遞迴的出口,從而輕鬆地實現連續的動畫效果:

if (Array.isArray(move) && move.length > 1) {
  const lastMove = move.pop()
  // 返回遞迴得到的 Promise
  return this.animate(move).then(() => this.animate(lastMove))
} else if (move.length === 1) move = move[0] // 繼續已有邏輯
複製程式碼

到這裡,一個可以供人體驗的魔方基本就可以在瀏覽器裡跑起來了。但這還不是我們最終的目標:我們該怎麼自動還原一個魔方呢?

魔方的還原

魔方的還原演算法在學術界已有很深入的研究,計算機在 20 步之內可以解出任意狀態的魔方,也有成熟的輪子可以直接呼叫。但作為一個(高中時)曾經的魔方業餘愛好者,筆者這裡更關注的是「如何模擬出我自己還原魔方的操作」,故而在這裡我們要介紹的是簡單易懂的 CFOP 層先演算法。

在開始前,有必要強調一個前文中一筆帶過的概念:在旋轉時,魔方中心塊之間的相對位置始終不會發生變化。如下圖:

cube-centers

因此,在魔方旋轉時,我們只需關注角塊和稜塊是否歸位即可。在 CFOP 層先法中,歸位全部角塊和稜塊的步驟,被分為了逐次遞進的四步:

  1. 還原底部四個稜塊,構建出「十字」。
  2. 分組還原底層和第二層的所有角塊和稜塊。
  3. 調整頂層塊朝向,保證頂面同色。
  4. 調整頂層塊順序,完成整個求解。

讓我們依次來看看每一步都發生了什麼吧。

底層十字

這一步可以說是最簡單也最難的,在此我們的目標是還原四個底部稜塊,像這樣:

cube-cross-end

對一個完全打亂的魔方,每個目標稜塊都可能以兩種不同的朝向出現在任意一個稜塊的位置上。為什麼有兩種朝向呢?請看下圖:

cube-cross-a

這是最簡單的一種情形,此時直接做一次 R2 旋轉即可使紅白稜塊歸位。但下面這種情況也是完全合法的:

cube-cross-b

這時由於稜塊的朝向不同,所需的步驟就完全不同了。但總的來說,構成十字所需的稜塊可能出現的位置總是有限的。拆解分類出所有可能的情形後,我們不難使用貪心策略來匹配:

  1. 每次找到一個構成十字所需的稜塊,求出它到目標位置的一串移動步驟。
  2. 在不影響其他十字稜塊的前提下將其歸位,而後尋找下一個稜塊。

這個最簡單的策略很接近語法分析中向前看符號數量為 1 時的演算法,不過這裡不需要回溯。實現機制可以抽象如下:

solveCross () {
  const clonedCube = new Cube(null, this.cube.moves)
  const moveSteps = []
  while (true) {
    const lostEdgeCoords = findCrossCoords(clonedCube)
    if (!lostEdgeCoords.length) break
    moveSteps.push(solveCrossEdge(clonedCube, lostEdgeCoords[0]))
  }
  return moveSteps
}
複製程式碼

這個實現原理並不複雜,其代價就是過小的區域性最優造成了較多的冗餘步驟。如果同時觀察 2 個甚至更多的稜塊狀態並將其一併歸位,其效率顯然能得到提升(這時的實現難度也是水漲船高)。作為對比,一流的魔方玩家可以在 7 步內完成十字,但這個演算法實現卻需要 20 步左右——不過這裡意思已經到了,各位看官就先不要太苛刻啦。

底部兩層

這裡的目標是在底部十字完成的基礎上,完成底部兩層所有塊的歸位。我們的目標是實現這樣的狀態:

cube-f2l-solved

這個步驟中,我們以 Slot 和 Pair 的概念作為還原的基本元素。相鄰的十字之間所間隔的一個稜和一個角,構成了一個 Slot,而它們所對應的兩個目標塊則稱為一個 Pair。故而這個步驟中,我們只需要重複四次將 Pair 放入 Slot 中的操作即可。一次最簡單的操作大概是這樣的:

cube-f2l-pair

上圖將頂層的一對 Pair 放入了藍紅相間的 Slot 中。類似於之前解十字時的情形,這一步中的每個稜塊和角塊也有不同的位置和朝向。如果它們都在頂層,那麼我們可以通過已有的匹配規則來實現匹配;如果它們在其它的 Slot 中,那麼我們就遞迴地執行「將 Pair 從其它 Slot 中旋出」的演算法,直到這組 Pair 都位於頂層為止。

這一步的還原演算法與下面的步驟相當接近,稍後一併介紹。

頂層同色與頂層順序

完成了前兩層的還原後,我們最後所需要處理的就是頂層的 8 個稜塊與角塊了。首先是頂面同色的步驟,將各塊調整到正確的朝向,實現頂面同色(一般採用白色作為底面,此時按照約定,黃色為頂面):

cube-oll

而後是頂層順序的調整。這一步在不改變稜與角朝向的前提下,改變它們的排列順序,最終完成整個魔方的還原:

cube-pll

從前兩層的還原到頂層的還原步驟中,都有大量的魔方公式規則可供匹配使用。如何將這些現成的規則應用到還原演算法中呢?我們可以使用規則驅動的方式來使用它們。

規則驅動設計

瞭解編譯過程的同學應該知道,語法分析的過程可以通過編寫一系列的語法規則來實現。而在魔方還原時,我們也有大量的規則可供使用。一條規則的匹配部分大概是這樣的:

cube-oll-demo

在頂面同色過程中,滿足上述 "pattern" 的頂面,可以通過 U L U' R' U L' U' R 的步驟來還原。類似地,在還原頂層順序時,規則的匹配方式形如這樣:

cube-pll-demo

滿足這條規則的頂層狀態可以通過該規則所定義的步驟求解:R2 U' R' U' R U R U R U' R。這樣一來,只需要實現對規則的匹配和執行操作,規則的邏輯就可以完全與程式碼邏輯解耦,變為可配置的 JSON 格式資料。用於還原前兩層的一條規則格式形如:

{
  match: { [E]: topEdge(COLOR_F, E), [SE]: SE_D_AS_F },
  moves: "U (R U' R')"
}
複製程式碼

頂層同色的規則格式形如:

{
  match: { [NW]: L, [NE]: R, [SE]: R, [SW]: L },
  moves: "R U R' U R U' R' U R U U R'"
}
複製程式碼

頂面順序的規則格式形如:

{
  match: { [N]: W, [W]: [E], [E]: N },
  moves: "R R U' R' U' R U R U R U' R"
}
複製程式碼

這裡的 NW / E / SE 是筆者的實現中基於九宮格東西南北方向定位的簡寫。在實現了對規則的自動匹配和應用之後,CFOP 中後面三步的實現方式可以說大同小異,主要的工作集中在一些與旋轉相關的 mapping 處理。

規則的自測試

在整個還原過程中,一共有上百條規則需要匹配。對於這麼多的規則,該如何保證它們的正確性呢?在 TDD 測試驅動開發的理念中,開發者需要通過編寫各種繁冗的測試用例來實現對程式碼邏輯的覆蓋。但在魔方領域,筆者發現了一種優雅得多的性質:任何一條規則本身,就是自己的測試用例!如這條規則:

{
  match: { [N]: W, [W]: [E], [E]: N },
  moves: "R R U' R' U' R U R U R U' R"
}
複製程式碼

我們只需要將 moves 中的每一步順序顛倒地輸入初始狀態的魔方,就可以用這個狀態來驗證規則是否能夠匹配,以及魔方是否能基於該規則還原了。這個性質使得我很容易地編寫了下面這樣簡單的程式碼,自動驗證每條輸入規則的正確性:

const flip = moves => moves.map(x => x.length > 1 ? x[0] : x + "'").reverse()

OLL.forEach(rule => {
  const rMoves = flip(rule.moves)
  const cube = new Cube(null, rMoves)
  if (
    matchOrientationRule(cube, rule) &&
    isOrientationSolved(cube.move(rule.moves))
  ) {
    console.log('OLL test pass', rule.id)
  } else console.error('Error OLL rule match', rule.id)
})
複製程式碼

在這個支援自測試的規則匹配演算法基礎上,求解魔方的全部步驟就這樣計算出來了 :)

成果與後記

經過半個多月業餘時間的折騰,筆者實現了一個非常小巧的魔方求解模擬器 Freecube。它支援三階魔方狀態的渲染和逐步求解,還提供了旋轉與求解的 API 可供複用。由於它沒有使用任何第三方依賴並使用了各種力求精簡的「技巧」,因而它的體積被控制在了壓縮後 10KB 內。歡迎移步 GitHub 觀光 XD

Freecube 是筆者在很多地方忙裡偷閒地實現的:咖啡廳、動車、公交車甚至飯桌上……即便寫不了程式碼的場合,也可以拿平板寫寫畫畫來設計它。它的靈感來自於 @youngdro 神奇的吉他和絃演算法博文,另外感謝大佬的指點和某人對 README 文件的審校 XD

相關文章