[Side Project] 把任何網頁都果凍化 (Jellify) 的書籤

上次用 Google Apps Script 做的 side project 就比較商業化,這次來做一個比較有趣的 side project。上次比較可惜的是沒有完整記錄做 side project 的過程,這次我從 idea 到做完的經過都把它紀錄下來,跟大家分享一下做這個 side project 的種種過程與挑戰。

最終成品

直接用一張 GIF 展示最快 (28MB 看不到請等他一下):


Idea 發想

做 side project 最難的就是無中生有,做人家做過的好像就沒什麼挑戰,做人家完全沒做過的也不知道能不能做出來。

一開始的構想

其實一開始我是想做 ANTLR + HTML canvas 的 side project。因為公司剛好也正在做 ANTLR 來 parse 一些東西,但對於這個 library 不熟自然就想多玩玩看,想說藉著 side project 也可以練習到。

不過 ANTLR 能做的事基本上還是要 parse 一種程式語言,不管是 C++ 程式碼還是 JSON 甚至到自訂格式的計算機。想了想覺得如果要用網頁的方式呈現還是對大部分的人有點生硬。

原本是想說讓大家在網頁上隨便輸入一串 JavaScript,讓大家一步一步看他被 syntax highlighting 的過程。後面想說更有趣的話我可以搭配 canvas 和變成考題的方式,類似之前有看過 JavaScript quiz,輸入正確的 JavaScript 否則寫錯的程式碼就會在網頁上炸開來。

如果要讓字母爆炸就代表我需要自己知道 JavaScript 每一個字要上什麼顏色,就會需要 ANTLR。要爆炸需要一個物理引擎。

延伸的構想

後來覺得實在太困難,ANTLR 我又不熟、物理引擎我也不熟,這樣兩個不熟加起來就很容易做不出來,而且好像也不太有趣。

但是為了調查能不能做出來的時候我有跑去看一個找到的物理引擎 Matter.js 的 demo,還有看到 Matter.js GitHub 的 gallery 那邊也有大家有趣的應用,看到一堆物件撞來撞去還有類似子彈時間覺得滿有趣的,就在想能不能把 ANTLR 換掉成什麼,於是就想到,不如我來讓網頁上的元件都變成物理世界的模擬對象,會不會滿好玩的?

最終構想

如果就單純把網頁上的物件變成物理引擎中的物體,那東西撞一撞之後網頁就散成一攤,你也沒辦法繼續瀏覽下去,好像就到此為止了。

所以我勢必得想辦法讓網頁可以繼續瀏覽下去,不然其實物理引擎玩一玩就無聊關掉了。在看了其中一個 Matter.js 的 demo "Soft Body" 之後發現那樣一堆圓形物體綁在一起Q彈的樣子給人一種爽感,就想到說不定我可以學他一樣把網頁的物體綁在一起,雖然會彈來彈去但始終會恢復原狀,所以就可以繼續瀏覽,有種惡搞的感覺。

什麼時候該被彈來彈去? 原本想說是靠使用者的滑鼠點下去會把物體彈開來,或是可以拖曳甩來甩去,可是我想說如果真的要一定可以繼續瀏覽網頁,不如做成其實都一直可以點。加上以前學物理時常常有那個公車突然加速站在上面的人會往後倒的靈感,就想說可以讓網頁的視窗想成公車,裡面的元素想成站在公車上的人。


Survey 其他書籤

既然我想讓每個網頁都可以被果凍化,最簡單讓大家嘗試的方式就是做一個書籤,可以很輕鬆拖曳到瀏覽器的書籤列測試。於是我就上網調查了一些比較 popular 的書籤:

  • Kick Ass: 把網頁變成 Space Invaders 遊戲
  • fontBomb: 在網頁上放炸彈,把字體炸開 (很可惜他程式目前有點問題,需要手動修復才能 work)
  • Katamari Hack: 把網頁變成 Katamari 遊戲
  • Slightly Evil JavaScript: 收集滿多拿來整人的小書籤

其中印象最讓我深刻的就是 Kick Ass,不只網站做的很好看,甚至還可以客製化戰鬥機的樣式。fontBomb 和 Katamari Hack 都是運用 CSS transform 來達成目標。

做書籤的第一個問題就是如何把 script inject 到網頁內,這方面我就參考了他們的書籤內容,大部分都是用 script.onload 等 inject 的 script 讀取一瞬間就開始跑想做的事。但我覺得程式碼太過複雜,所以改用 setInterval 每 100 毫秒等一次看看 jQuery 和 Matter.js 好了沒,好了我才真的做事。不只 code 簡單也比較好維護,畢竟我對載入速度也沒那麼要求。

Design Spec 只有一張圖

好了,既然我們腦中有 functional spec,接著就是想說如何設計。一開始想到的就是用 jQuery 去把任何網頁的元件讀取後建一個 tree。但是馬上就發現有時候從上往下的元素 (element)  A -> B -> C 雖然 A 的面積包含 C,但 B 卻在網頁上沒有任何顯示空間,甚至是 1x1 躲在一個網頁最左上角的小角落。

我想原因應該就是網頁設計常常把 relative/absolute 交錯在一起的排版技巧,但卻會造成我莫大的困擾,因為我想讓大元素包含小元素,讓他們一起搖來搖去,那我就要把 B 這類元素排除掉,卻還是想保留 A-C 這個關係。

於是我就隨便網路上找一個 online drawing 工具 (Sketchpad) 畫了第一個圖片來想想看要怎麼實作:


假設 100x0 這個節點需要被刪除,那我們應該把 100x300 - 3x10 與 100x300 - 30x50 這個 edge 把他接回去。

一開始的演算法很有瑕疵:

  1. 先建好 element tree,先不管他能不能顯示或在網頁上的呈現區域如何
  2. 在 element tree 上把中間這樣 user 看不到的 B 節點刪除
  3. 刪除 B 後從下往上修復這個 tree,例如 B 被刪除的話,從 C 往上找找看 A 是不是方框可以包含 C 的方框,如果是的話就把 A-C 連結起來
  4. 連結起來新的 tree 我把它叫做 visual tree,我們不會動到原本的 element tree

寫完 JavaScript 後第一件事其實不是跑去用物理引擎,而是先把建好的 tree render 出來看看有沒有 bug。我的作法很簡單,就是把 tree 上的 nodes 用 border 的方式在網頁上畫出來給我看看:


恩,看了看覺得好像沒問題,於是我就繼續跑去做物理引擎的部分了。

等做完物理引擎後我們再回來公布瑕疵在哪。上面演算法其中一個瑕疵會讓我後面 debug 超久。

物理引擎 Prototyping

我從來沒碰過任何物理引擎,所以直接寫在我書籤的 code 裡面到時候發生問題感覺會很難 debug。這時候就非常適合先做一個小小的 prototype,把 Matter.js 搞懂來再來和我的書籤進行整合。

因為 Matter.js "Soft Body" 那個範例最接近我想要做的,於是我就 Google 找了一個別人在 CodePen 上做的一個小範例:


於是就試著改寫看看。

第一個我最好奇的就是 Soft Body 是不是一定要照他那樣 grid 排開來,網頁元素又不一定會乖乖排成那樣,所以我就對 Matter.js 的 Composites API 試了一下 (改寫後的 code):


我故意讓第一排的 x 座標往右邊移動 10,看到原來是真的可以不一定要照 grid。再去看 Matter.js Composites.mesh 的 source code 在做什麼才發現原來他在做的只是把一個一個圓圈的 Body 把他們用類似一條彈簧的 Constraint 連接起來。

那我是不是可以自己建 Constraint 把網頁的元素串起來? 於是我就進行下一個測試,看看能不能把元素當成長方形,再用 Constraint 把他們的角落連接起來 (改寫後的 code):


發現其實辦的到的時候,我再把裡面小長方形最左邊的那個的移動位置和旋轉量用 CSS transform 套到左下角那個按鈕。

這其實後來也是我把網頁元素在串起來的方式,我想讓一排的 sibling elements 各自串接起來,然後斜對角再由 parent element 綁住,讓他有機會搖完之後慢慢恢復原狀。

建構物體的方式

我設計讓 Matter.js 世界中可以拆成幾個部分:

  • Body: 長方形的,代表網頁中的元素
  • Inter constraint: 同層級的網頁元素之間的連結,數量就看同層級的元素有多少個,如果同層級只有 1 個那就沒有 inter constraints,越多個同層級的元素應該就可以建更多的 inter constraints
  • Diagonal constraint: 同層級網頁元素與 parent 元素之間的連結,而我會找最接近的點來連結,所以 diagonal constraint 一律都是 4 個

用上面那張圖片來講解的話 (用 Sketchpad 畫的):


後面對應到的 visual tree 會長這樣:


我們可以看的出來 A 包含 B 的面積、B 包含 C 的面積、B 也包含 D 的面積。

Design Spec 的瑕疵

還記得我們上面提到說如何建上面這個 visual tree 其中一個步驟:

刪除 B 後從下往上修復這個 tree,例如 B 被刪除的話,從 C 往上找找看 A 是不是方框可以包含 C 的方框,如果是的話就把 A-C 連結起來

(這邊的文字裡面提到的 A, B, C 和上面那張圖無關)

我就照這樣想法下去寫 code,加上 Matter.js 之後 render 出來就覺得怪怪的,為什麼應該要連為一體的網頁元素有時候卻被拆開成兩個? 甚至用滑鼠把方框拖曳走之後會留下一個沒有任何人連結他的方框,他就很孤單的在那邊飄來飄去。

但網頁上有一堆 elements (元素),Matter.js render 出來的結果也有一堆長方形,我要怎麼 debug?

第一件事就只能先把有問題的地方孤立出來,這樣用 console.log 印東西出來才可以針對有問題的地方,我記得我就插入了一堆類似這樣的 code:


if (node.getID() !== 'id("root")/DIV[1]/MAIN[1]/SECTION[7]/P[1]') return;

console.log(`Parent node: ${node.getID()}`);
console.log(`Bounding box = ${JSON.stringify(node.getBoundingBox())}`);

雖然看起來很笨,但其實這時候用印的方式比在 Chrome DevTools 設中斷點來的好,因為我可以照我想要印出來的方式中看到我的邏輯缺陷在哪。除非是一些 null 問題我才會用 DevTools。

Debug 了超久才意識到原來網頁中的元素有可能發生這樣的結構:


就是因為 visual tree 建立方向是 "從下往上" 去建 parent-child 關係,C 往上找只有 A 包含 (contains) 他,所以連接 A-C;B 也往上找到只有 A 包含他,所以連接 A-B。再加上我是從 element tree 上去做 iteration,所以總結起來才會出這樣的問題。

雖然這看起來也只是 spec 問題,但這樣 A, B, C 連結起來我總覺得怪怪的,思考了一下到底怪在哪? 問我自己後我才想出問題癥結點:

我不想要長方形之間有 overlapping 的情形。

圖片中的 B 很有可能其實 user 看不出來,就只是為了排版弄出來卡在中間的 element,那我其實對他沒興趣,所以後來這個 commit 附近就大概是在修這個問題。

我改成 "從上往下" 去建 visual tree,A-B 會連結但是 B-C 就不會連結,那我搖晃 B 其實也間接搖晃到 C。但比較重要的因為不會 overlapping 所以在建 diagonal 和 inter constraints 的時候就不會呈現的樣子怪怪的。

在不同電腦測試才發現的 Bug

原本在桌電寫完,還架設了網站,看似都 OK。到筆電測試的時候才突然發現怎麼自己建的網頁中保持平靜的狀況下卻會有小熊軟糖跑掉的情形:


綠色的小熊軟糖應該要和藍色的小熊軟糖建 inter constraint 才對,但綠色軟糖卻跑去跟離他比較遠的橘色軟糖連結?

如果我們把 element isolate 出來,限制演算法只針對最左邊的綠色小熊軟糖,看 Matter.js canvas 會像這樣:


一樣用印的方式,開啟 debug mode 把 Matter.js render 出來,再加上 console 把演算法做的事印出來,赫然發現 3 個問題:

  1. 如果兩個長方形的點幾乎重疊在一起,用 Matter.Vector.angle(point1, point2) 會回傳 180 度
  2. 就算兩個點看似在 x 座標非常平行,Element.getBoundingClientRect() 卻有時候會回傳極小浮點數的誤差
  3. 取距離我用到 Matter.Vector.distanceSquared 的 API,其實應該要用 Matter.Vector.distance 才對

    因為我有限制每個框的 corner point 在建立 inter constraints 時要去找最近的其他框的 corner point 時會看是哪個角落限制他的角度,也就是數學裡面的第幾象限的感覺:


    舉例來說右上角的點我限制只能找 0-90 度的點,左上角的點我限制只能找 90-180 度的點,左下角的點我限制只能找 180-270 度的點,右下角則是限制 270-360 度。

    如果沒限制角度的話連結完的樣子可能會很扭曲,導致有些方框後來就不會想回到一開始的位置去,就不是我要的。

    雖然問題 #3 不會影響演算法,但是問題 #1 和 #2 卻會因此壞掉。所以我就作了以下的修正:

    • 如果兩個點夠近 (例如 <= 1) 那就忽略角度限制
    • 改用 Matter.Vector.distance 算距離 (也讓人比較好 debug)

    這個 Bug 也就迎刃而解。

    Element 到 Matter.js Object 要如何 Mapping?

    書籤裡面有一個部分是把 node 對應到 Matter.js object 的 object,這樣我先建完 Matter.js Body 後再去建 constraints 我才有機會去抓已經建好的 Matter.js Body。

    由於我的 element 是用我自己做的一個 TreeNode class 來代表,一開始 Google 查了一下怎麼把 class instance 當成 object 的 key 就查到可以用 ES6 新出的 Map,可是試了幾下就怕遇到這樣的問題:

    
    const map1 = new Map();
    
    class Test {
      constructor(key) {
        this.key = key;
      }
    }
    
    const test1 = new Test('a');
    const test2 = new Test('a');
    const test3 = new Test('b');
    
    map1.set(test1, 1);
    map1.set(test2, 2);
    map1.set(test3, 3);
    
    console.log(map1.get(test1));
    console.log(map1.get(test2));
    console.log(map1.get(test3));
    

    答案印出來會是 1, 2, 3,但是 test1 和 test2 我希望他是同一種 key。就怕這樣會產生什麼 bug 或讓人不好理解,所以我想用 string 直接當作 key,可是 element 有什麼 unique ID 可以當作他的 key?

    後來就想到 DevTools 裡面不是可以 copy element 的 XPath 嗎? 我就去找到 StackOverflow 有產生 XPath 的一小段程式碼,於是就借來用用。

    的確對後來的 debugging 有很大的幫助,我直接看 XPath 我還可以利用 Chrome DevTools 提供的 $x 去在網頁上找到是哪個 element 有問題。

    CSS Transform 遇到 position: fixed, absolute, sticky 會壞掉

    全部都做完後抓一些網頁來測試就發現例如 Matter.js documentation 的頁面套上去後左邊的捲軸就消失了。甚至有些網站一套用就整個排版亂掉。後來觀察出這些壞掉的 element 通常原本都是 position: fixed 或 position: absolute。

    去查了一下才發現好像真的 CSS transform 目前碰到這種元素就有機會壞掉 (StackOverflow),而且就連他的 parent 如果也是 fixed/absolute/sticky 呈現的時候就會怪怪的。

    後來我在建 visual tree 的時候會先去偵測一個 element 他自己本身或他的 ancestors 是不是都沒有 fixed/absolute/sticky 我才納入考量。後來看起來好像就不明所以的解決了,所以 navbar 那類的東西你會發現書籤都不會去動他。

    requestAnimationFrame 不同調的問題

    你如果去看大部分的 Matter.js 範例會發現他們幾乎都是用 Matter.Runner 來去跑動畫與更新引擎的。所以我一開始也這麼用。

    但我後來因為要測量使用者用滑鼠中鍵的滾輪或拉卷軸的加速度,以及我要 update CSS transform,所以額外用了兩個 requestAnimationFrame,立刻就發現怎麼卷軸突然捲動後會卡一陣子 elements 才跟著動。

    後來才發現 Matter.Runner 裡面應該也有自己的 requestAnimationFrame,這樣會變成其實三個東西同時平行在運行:

    • Matter.Runner 的 requestAnimationFrame
    • 測量加速度的 requestAnimationFrame
    • 更新 element CSS transform 的 requestAnimationFrame

    我不如就用一個 requestAnimationFrame 就好,如果三個東西各自跑,只要一方多跑幾下,而那幾下我都沒 sync 到狀態當然整個 animation 就會不好看。

    但如果我不用 Matter.Runner 要怎麼去更新他後面的 engine? 所以我就查到原來可以自己用 requestAnimationFrame 呼叫到的 callback 中呼叫 Matter.Engine.update。

    Matter.Engine.update 有 delta & correction 兩個欄位可以餵進去,就是在處理 requestAnimationFrame 呼叫我們的 callback 可能不是那麼 smooth 的狀態下,Matter.js 想知道兩個 frames 之間時間差過了多久,有一個 frame 突然變慢很多的話,我們可以讓物體飛更快一點假裝中間的事都沒發生。當然我也發現如果網頁越慢的話,delta & correction 越大也會讓物體搖晃的更兇,但這點我不知道是不是 Matter.js 目前還做不好的地方,所以我目前就是單純限制 FPS 在 30-60 讓他差異不要那麼大而已。

    網頁太卡了,怎麼優化效能?

    測試了幾個網站後遇到一個大魔王就是 reddit 網站本身。因為裡面的 elements 數量超級多,版型又是一個極狹長的長方形,這代表 Matter.js 世界就必須跟他一起建這麼長,Body 也超級多,對於物理引擎來說就是一種負擔,結果就看起來卡卡的。

    既然使用者能看到的部分只佔了一小部分,我何必去運算底下看不到的部分呢?

    (圖片來源)

    World coordinates 代表整個網頁 document size 的話,viewport 就代表網頁中的 window size。

    於是我就把 viewport size 放大 5 倍,讓超出這個範圍的 element 都"暫時"停止運行。但 Matter.js 引擎一 定還是會繼續模擬這些看不到的 Object 我該怎麼辦?

    於是我就去翻 Matter.js 的 source code,看 Matter.Engine.update 裡面是怎麼去 update Body 的,就看到一個關鍵的程式碼:

    
                if (body.isStatic || body.isSleeping)
                    continue;
    

    所以我只要把物體設成 static 或 sleeping 他就不會對這樣的 Body 去做運算了,但我比較怕如果突然把一個物體設成 static 或 sleeping 的話可能會喪失他目前的速度和轉動慣量,所以我去看 Matter.js 的 source code 看到只有 Body.setStatic 會把原本的速度資訊存下來,Sleeping.set 卻不會 。

    (其實在還沒寫到這邊前我字面以為用 sleeping 才不會,是為了打這段解說才突然發現我搞錯了 XD。趕快修正後再這邊繼續寫。)

    做了這個修正後網頁終於就不會再卡卡了。

    總結

    我們在學校資工系時常常會問為什麼要學物理? 那也真的是我滿討厭的一個課程,可是如果物理的基礎不好,我也可能就推不出視窗加速度與力量之間的轉換,更何況我還要直接使用物理引擎,裡面有 damping、stiffness 的概念去看解說時也比較好快速理解。

    數學和幾何其實也是充斥了整個書籤的 code,你應該可以看到我自己寫了一個 GeometryUtil 在處理像是 rectangle 是不是 contains 另外一個 rectangle。還有我用了大量的 Matter.Vector 底下的 API 在幫我處理像是兩個 vector 之間的角度。

    做這個書籤是越做越覺得有趣,原本看起來普通的 idea 做出來還真的滿好玩的。甚至還第一次用 React.js 架網站,也學習到不少 React 的用法,這我有機會再用別的文章來分享吧。

    留言

    發佈留言

    此網誌的熱門文章

    [試算表] 追蹤台股 Google Spreadsheet (未實現損益/已實現損益)

    [Side Project] 互動式教學神經網路反向傳播 Interactive Computational Graph

    [插件] 在 Chrome 網頁做區分大小寫的搜尋