基於 HTML5 的 WebGL 3D 隧道監控

圖撲軟體發表於2019-03-18

前言

監控隧道內的車道堵塞情況、隧道內的車禍現場,在隧道中顯示當前車禍位置並在隧道口給與提示等等功能都是非常有必要的。這個隧道 Demo 的主要內容包括:照明、風機、車道指示燈、交通訊號燈、情報板、消防、火災報警、車行橫洞、風向儀、微波車檢、隧道緊急逃生出口的控制以及事故模擬等等。

效果圖:
圖片描述

http://www.hightopo.com/demo/tunnel2/index.html

上圖中的各種裝置都可以雙擊,此時 camera 的位置會從當前位置移動到雙擊的裝置的正前方;隧道入口的展示牌會自動輪播,出現事故時會展示牌中的內容會由“限速80,請開車燈”變為“超車道兩車追尾,請減速慢行”;兩隧道中間的逃生通道上方的指示牌是可以點選的,點選切換為藍綠色啟用狀態,兩旁的逃生通道門也會開啟,再單擊指示牌變為灰色,門關閉;還有一個事故現場模擬,雙擊兩旁變壓器中其中一個,在隧道內會出現一個“事故現場圖示”,單擊此圖示,出現彈出框顯示事故等等等等。

程式碼實現

場景搭建

整個隧道都是基於 3D 場景上繪製的,先來看看怎麼搭建 3D 場景:

dm = new ht.DataModel(); // 資料容器
g3d = new ht.graph3d.Graph3dView(dm); // 3d 場景
g3d.addToDOM(); // 將場景新增到 body 中

上面程式碼中的 addToDOM 函式,是一個將元件新增到 body 體中的函式的封裝,定義如下:

addToDOM = function(){
    var self = this,
         view = self.getView(), // 獲取元件的底層 div
         style = view.style;
    document.body.appendChild(view); // 將元件底層 div 新增進 body 中
    style.left = '0'; // ht 預設將所有的元件的 position 都設定為 absolute 絕對定位
    style.right = '0';
    style.top = '0';
    style.bottom = '0';
    window.addEventListener('resize', function () { self.iv(); }, false); // 視窗大小改變事件,呼叫重新整理函式
}

JSON 反序列化

整個場景是由名為 隧道1.json 的檔案匯出而成的,我只需要用程式碼將 json 檔案中的內容轉換為我需要的部分即可:

ht.Default.xhrLoad('./scenes/隧道1.json', function(text) { // xhrLoad 函式是一個非同步載入檔案的函式
    var json = ht.Default.parse(text); // 將 json 檔案中的文字轉為我們需要的 json 格式的內容
    dm.deserialize(json); // 反序列化資料容器,解析用於生成對應的 Data 物件並新增到資料容器 這裡相當於把 json 檔案中生成的 ht.Node 節點反序列化到資料容器中,這樣資料容器中就有這個節點了
});

由於 xhrLoad 函式是一個非同步載入函式,所以如果 dm 資料容器反序列化未完成就直接呼叫了其中的節點,那麼會造成資料獲取不到的結果,所以一般來說我是將一些邏輯程式碼寫在這個函式內部,或者給邏輯程式碼設定 timeout 錯開時間差。

首先,由於資料都是儲存在 dm 資料容器中的(通過 dm.add(node) 新增的),所以我們要獲取資料除了可以通過 id、tag 等獨立的方式,還可以通過遍歷資料容器來獲取多個元素。由於這個場景比較複雜,模型的面也比較多,鑑於裝置配置,我將能 Batch 批量的元素都進行了批量:

dm.each(function(data) {
    if (data.s('front.image') === 'assets/sos電話.png'){ // 對“電話”進行批量
        data.s('batch', 'sosBatch');
    }
    else if (data.s('all.color') === 'rgba(222,222,222,0.18)') { // 逃生通道批量(透明度也會影響效能)
        data.s('batch', 'emergencyBatch');
    }
    else if (data.s('shape3d') === 'models/隧道/攝像頭.json' || data.s('shape3d') === 'models/隧道/橫洞.json' || data.s('shape3d') === 'models/隧道/捲簾門.json') {
        if(!data.s('shape3d.blend')) // 個別攝像頭染色了 不做批量
            data.s('batch', 'basicBatch'); // 基礎批量什麼也不做
    }
    else if (data.s('shape3d') === 'models/大型變壓器/變壓器.json') {    
        data.s('batch', 'tileBatch');
        data.setToolTip('單擊漫遊,雙擊車禍地點出現圖示');
    }
    else if (data.getDisplayName() === '地面') {
        data.s('3d.selectable', false); // 設定隧道“地面”不可選中
    }
    else if (data.s('shape3d') === 'models/隧道/排風.json') {
        data.s('batch', 'fanBatch'); // 排風扇的模型比較複雜,所以做批量
    }
    else if (data.getDisplayName() === 'arrow') { // 隧道兩旁的箭頭路標
        if (data.getTag() === 'arrowLeft') data.s('shape3d.image', 'displays/abc.png');
        else data.s('shape3d.image', 'displays/abc2.png');
        data.s({
            'shape3d': 'billboard',
            'shape3d.image.cache': true, // 快取,設定了 cache 的代價是需要設定 invalidateShape3dCachedImage
            'shape3d.transparent': true // 設定這個值,圖片上的鋸齒就不會太明顯了(若圖片型別為 json,則設定 shape3d.dynamic.transparent)
        });
        g3d.invalidateShape3dCachedImage(data);
    }
    else if (data.getTag() === 'board' || data.getTag() === 'board1') { // 隧道入口處的情報板
        data.a('textRect', [0, 2, 244, 46]); // 業務屬性,用來控制文字的位置[x,y,width,height]
        data.a('limitText', '限速80,請開車燈'); // 業務屬性,設定文字內容
        var min = -245;
        var name = 'board' + data.getId();
        window[name] = setInterval(function() {
            circleFunc(data, window[name], min) // 設定情報板中的文字向左滾動,並且當文字全部顯示時重複閃爍三次
        }, 100);
    }

    // 給逃生通道上方的指示板 動態設定顏色
    var infos = ['人行橫洞1', '人行橫洞2', '人行橫洞3', '人行橫洞4', '車行橫洞1', '車行橫洞2', '車行橫洞3'];
    infos.forEach(function(info) {
        if(data.getDisplayName() === info) {
            data.a('emergencyColor', 'rgb(138, 138, 138)');
        }
    });

    infos = ['車道指示器', '車道指示器1', '車道指示器2', '車道指示器3'];
    infos.forEach(function(info) {
        if (data.getDisplayName() === info) {
            createBillboard(data, 'assets/車道訊號-過.png', 'assets/車道訊號-過.png', info) // 考慮到效能問題 將六面體變換為 billboard 型別元素
        }
    });
});

上面有一處設定了 tooltip 文字提示資訊,在 3d 中,要顯示這個文字提示資訊,就需要設定 g3d.enableToolTip() 函式,預設 3d 元件是關閉這個功能的。

邏輯程式碼

情報板滾動條

我就直接按照上面程式碼中提到的方法進行解釋,首先是 circleFunc 情報板文字迴圈移動的函式,在這個函式中我們用到了業務屬性 limitText 設定情報板中的文字屬性以及 textRect 設定情報板中文字的移動位置屬性:

function circleFunc(data, timer, min) { // 設定情報板中的文字向左滾動,並且當文字全部顯示時重複閃爍三次
    var text = data.a('limitText'); // 獲取當前業務屬性 limitText 的內容
    data.a('textRect', [data.a('textRect')[0]-5, 2, 244, 46]); // 設定業務屬性 textRect 文字框的座標和大小
    if (parseInt(data.a('textRect')) <= parseInt(min)) {
        data.a('textRect', [255, 2, 244, 46]);
    }
    else if (data.a('textRect')[0] === 0) {
        clearInterval(timer);
        var index = 0;
        var testName = 'testTimer' + data.getId(); // 設定多個 timer 是因為能夠進入這個函式中的不止一個 data,如果在同一時間多個 data 設定同一個 timer,那肯定只會對最後一個節點進行動畫。後面還有很多這種陷阱,要注意
        window[testName] = setInterval(function() {
            index++;
            if(data.a('limitText') === '') { // 如果情報板中文字內容為空
                setTimeout(function() {
                    data.a('limitText', text); // 設定為傳入的 text 值
                }, 100);
            }
            else {
                setTimeout(function() {
                    data.a('limitText', ''); // 若情報板中的文字內容不為空,則設定為空
                }, 100);
            }

            if(index === 11) { // 重複三次 
                clearInterval(window[testName]);
                data.a('limitText', text);
            }
        }, 100);

        setTimeout(function() {
            timer = setInterval(function() {
                circleFunc(data, timer, min) // 回撥函式
            }, 100);
        }, 1500);
    }
}

由於 WebGL 對瀏覽器的要求不低,為了能儘量多的適應各大瀏覽器,我們將所有的“道路指示器” ht.Node 型別的六面體全部換成 billboard 型別的節點,效能能提升不少。

圖片描述

http://www.hightopo.com

設定 billboard 的方法很簡單,獲取當前的六面體節點,然後給這些節點設定:

node.s({
    'shape3d': 'billboard',
    'shape3d.image': imageUrl,
    'shape3d.image.cache': true
});
g3d.invalidateShape3dCachedImage(node); // 還記得用 shape3d.image.cache 的代價麼?

當然,因為 billboard 不能雙面顯示不同的圖片,只是一個“面”,所以我們還得在這個節點的位置建立另一個節點,在這個節點的“背面”顯示圖片,並且跟這個節點的配置一模一樣,不過位置要稍稍偏移一點。

Camera 緩慢偏移

其他動畫部分比較簡單,我就不在這裡多說了,這裡有一個雙擊節點能將視線從當前 camera 位置移動到雙擊節點正前方的位置的動畫我提一下。我封裝了兩個函式 setEye 和 setCenter,分別用來設定 camera 的位置和目標位置的:

function setCenter(center, finish) { // 設定“目標”位置
    var c = g3d.getCenter().slice(0), // 獲取當前“目標”位置,為一個陣列,而 getCenter 陣列會在視線移動的過程中不斷變化,所以我們先拷貝一份
        dx = center[0] - c[0], // 當前 x 軸位置和目標位置的差值
        dy = center[1] - c[1],
        dz = center[2] - c[2];
    // 啟動 500 毫秒的動畫過度
    ht.Default.startAnim({
        duration: 500,
        action: function(v, t) {
            g3d.setCenter([ // 將“目標”位置緩慢從當前位置移動到設定的位置處
                c[0] + dx * v,
                c[1] + dy * v,
                c[2] + dz * v
            ]);
        }
    });
};

function setEye(eye, finish) { // 設定“眼睛”位置
    var e = g3d.getEye().slice(0), // 獲取當前“眼睛”位置,為一個陣列,而 getEye 陣列會在視線移動的過程中不斷變化,所以我們先拷貝一份
        dx = eye[0] - e[0],
        dy = eye[1] - e[1],
        dz = eye[2] - e[2];

    // 啟動 500 毫秒的動畫過度
    ht.Default.startAnim({
        duration: 500,
        action: function(v, t) { //將 Camera 位置緩慢地從當前位置移動到設定的位置
            g3d.setEye([
                e[0] + dx * v,
                e[1] + dy * v,
                e[2] + dz * v
            ]);
        }
    });
};

後期我們要設定的時候就直接呼叫這兩個函式,並設定引數為我們目標的位置即可。比如我這個場景中的各個模型,由於不同視角對應的各個模型的旋轉角度也不同,我只能找幾個比較有代表性的 0°,90°,180°以及360° 這四種比較典型的角度了。所以繪製 3D 場景的時候,我也儘量設定節點的旋轉角度為這四個中的一種(而且對於我們這個場景來說,基本上只在 y 軸上旋轉了):

var p3 = e.data.p3(), // 獲取事件物件的三維座標
    s3 = e.data.s3(), // 獲取事件物件的三維尺寸
    r3 = e.data.r3(); // 獲取事件物件的三維旋轉值

setCenter(p3); // 設定“目標”位置為當前事件物件的三維座標值
if (r3[1] !== 0) { // 如果節點的 y 軸旋轉值 不為 0
    if (parseFloat(r3[1].toFixed(5)) === parseFloat(-3.14159)) { // 浮點負數得做轉換才能進行比值
        setEye([p3[0], p3[1]+s3[1], p3[2] * Math.abs(r3[1]*2.3/6)]); // 設定 camera  的目標位置
    }
    else if (parseFloat(r3[1].toFixed(4)) === parseFloat(-1.5708)) {
        setEye([p3[0] * Math.abs(r3[1]/1.8), p3[1]+s3[1], p3[2]]);
    }
    else {
        setEye([p3[0] *r3[1], p3[1]+s3[1], p3[2]]);
    }
}
else {
    setEye([p3[0], p3[1]+s3[1]*2, p3[2]+1000]);
}

事故模擬現場

最後來說說模擬的事故現場吧,這段還是比較接近實際專案的。操作流程如下:雙擊“變壓器”–>隧道中間某個部分會出現一個“事故現場”圖示–>單擊圖示,彈出對話方塊,顯示當前事故資訊–>點選確定,則事故現場之前的燈都顯示為紅色×,並且隧道入口的情報板上的文字顯示為“超車道兩車追尾,請減速慢行”–>再雙擊一次“變壓器”,場景恢復事故之前的狀態。

在 HT 中,可通過 Graph3dView#addInteractorListener(簡寫為 mi)來監聽互動過程:

g3d.addInteractorListener(function(e) {
    if(e.kind === 'doubleClickData') {
        if (e.data.getTag() === 'jam') return; // 有“事故”圖示節點存在
        if (e.data.s('shape3d') === 'models/大型變壓器/變壓器.json') { // 如果雙擊物件是變壓器
            index++;
            var jam = dm.getDataByTag('jam'); // 通過唯一標識 tag 標籤獲取“事故”圖示節點物件
            if(index === 1){
                var jam = dm.getDataByTag('jam');
                jam.s({
                    '3d.visible': true, // 設定節點在 3d 上可見
                    'shape3d': 'billboard', // 設定節點為 billboard 型別
                    'shape3d.image': 'assets/車禍.png', // 設定 billboard 的顯示圖片
                    'shape3d.image.cache': true, // 設定 billboard 圖片是否快取
                    'shape3d.autorotate': true, // 是否始終面向鏡頭
                    'shape3d.fixSizeOnScreen': [30, 30], // 預設保持圖片原本大小,設定為陣列模式則可以設定圖片顯示在介面上的大小
                });
                g3d.invalidateShape3dCachedImage(jam); // cache 的代價是節點需要設定這個函式
             }
             else {
                 jam.s({
                     '3d.visible': false // 第二次雙擊變壓器就將所有一切恢復“事故”之前的狀態
                });
                dm.each(function(data) {
                    var p3 = data.p3();
                    if ((p3[2] < jam.p3()[2]) && data.getDisplayName() === '車道指示器1') {
                        data.s('shape3d.image', 'assets/車道訊號-過.png');
                    }
                    if(data.getTag() === 'board1') {
                        data.a('limitText', '限速80,請開車燈');
                    }
                });
                index = 0;
            }
                        
        }
    }
});

既然“事故”節點圖示出現了,接著點選圖示出現“事故資訊彈出框”,監聽事件同樣是在 mi(addInteractorListener)中,但是這次監聽的是單擊事件,我們知道,監聽雙擊事件時會觸發一次單擊事件,為了避免這種情況,我在單擊事件裡面做了延時:

else if (e.kind === 'clickData'){ // 點選圖元
    timer = setTimeout(function() {
        clearTimeout(timer);
        if (e.data.getTag() === 'jam') { // 如果是“事故”圖示節點
            createDialog(e.data); // 建立一個對話方塊
        }
    }, 200);
}

在上面的雙擊事件中我沒有 clearTimeout,怕順序問題給大家造成困擾,要記得加一下。

彈出框如下:

圖片描述

這個彈出框是由兩個 ht.widget.FormPane 表單構成的,左邊的表單只有一行,行高為 140,右邊的表單是由 5 行構成的,點選確定,則“事故”圖示節點之前的道路指示燈都換成紅色×的圖示:

function createForm4(node, dialog) { // 彈出框右邊的表單
    var form = new ht.widget.FormPane(); // 表單元件
    form.setWidth(200); // 設定表單元件的寬
    form.setHeight(200); // 設定表單元件的高
    var view = form.getView(); // 獲取表單元件的底層 div 
    document.body.appendChild(view); // 將表單元件新增到 body 中

    var infos = [
        '編輯框內容為:2輛',
        '編輯框內容為:客車-客車',
        '編輯框內容為:無起火',
        '編輯框內容為:超車道'
    ];
    infos.forEach(function(info) {
        form.addRow([ // 向表單中新增行
            info
        ], [0.1]); // 第二個引數為行寬度,小於1的值為相對值
    });
    
    form.addRow([
        {
            button: { // 新增一行的“確認”按鈕
                label: '確認',
                onClicked: function() { // 按鈕點選事件觸發
                    dialog.hide(); // 隱藏對話方塊
                    dm.each(function(data) {
                        var p3 = data.p3();
                        if ((p3[2] < node.p3()[2]) && data.getDisplayName() === '車道指示器1') { // 改變“車道指示器”的顯示圖片為紅色×,這裡我是根據“事故”圖示節點的座標來判斷“車道顯示器”是在前還是在後的
                            data.s('shape3d.image', 'assets/車道訊號-禁止.png');
                        }
                        if(data.getTag() === 'board1') { // 將隧道口的情報板上的文字替換
                            data.a('limitText', '超車道兩車追尾,請減速慢行');
                        }
                    });
                }
            }
        }
    ], [0.1]);
    return form;
}

結束語

這個工業隧道的 Demo 是我通過幾天不斷地完善完善而成的,可能還是有不足的地方,但是總體來說我是挺滿意的了,可能之後還會繼續完善,也得靠大家不斷地給我意見和建議,我只希望在自己努力的同時也可以幫助到別人。整個 Demo 中,我主要遇到了兩個問題,一個是我在程式碼中提到過的設定 timer 的問題,多個節點如果同時用一個 timer,那就只有最後一個節點能夠顯示出 timer 的效果;另一個是 getEye 和 getCenter 的問題,這兩個值都是在不斷變化的,所以得先拷貝一份資料,再進行資料的變換。

相關文章