基於 HTML5 + WebGL 的 3D 風力發電場

圖撲軟體發表於2020-04-06

前言

風能是一種開發中的潔淨能源,它取之不盡、用之不竭。當然,建風力發電場首先應考慮氣象條件和社會自然條件。近年來,我國海上和陸上風電發展迅猛。海水、陸地為我們的風力發電提供了很好地質保障。正是這些場地為我們的風力提供了用之不竭的能源。現在我們正在努力探索這些領域。

本文章實現了風力發電場的整體流程。能讓大家能夠看到一套完整風力發電預覽體系。

需要注意的是,本次專案是使用 Hightopo 的 HT for Web 產品來搭建的。

預覽地址:hightopo.com/demo/wind-p…

大致流程

下面是整個專案的流程圖。我們從首頁可以進入到場區分佈頁面和集控頁面。

場區分佈頁面又包括兩個不同的 3D 場景,分別是陸地風機場和海上風機場。點選兩個 3D 風機場最終都會進入到 3D 風機場景。

預覽效果

首頁:

1. 世界地圖效果

2. 中國地圖效果

2. 城市地圖效果

集控中心頁面(沒有動畫效果):

場區分佈頁面(沒有動畫效果)

陸地風機場:

海上風機場:

程式碼實現

我們可以看到,首頁的地球有三種視角狀態,世界地圖、中國地圖、城市地圖。點選每個狀態相機就會轉到對應的位置。在這之前我們要先預先存一下對應的 center 和 eye 。

我們最好新建一個 data.js 檔案,專門用來提供資料。

相關虛擬碼如下:

// 記錄位置
var cameraLocations = {
    earth: {
        eye: [-73, 448, 2225],
        center: [0, 0, 0]
    },

    china: {
        eye: [-91, 476, 916],
        center: [0, 0, 0]
    },

    tsankiang: {
        eye: [35, 241, 593],
        center: [0, 0, 0]
    }
}

好了,有了資料之後。我們接下來該監聽事件了。我們可以點選按鈕,也可以點選高亮區域(世界地圖只有按鈕可以點選)進入到中國地圖視角。

我們可以這樣先獲取這兩個節點,然後對它們的點選事件進行相同的處理。但是,我覺得這種方式可以進行優化,更換一種思考方式。

我們可以先將事件進行過濾,我們建立兩個陣列,一個儲存著類似 click、onEnter 這樣可以執行的事件,一個儲存著所有可以觸發事件的節點。這樣可以有利於我們維護,也可以使結構更加清晰。

下圖,我們可以看到,如果當前節點沒有事件許可權或者當前事件本身就沒有許可權的話,就會被過濾掉。如果都可以正確返回,則執行對應的事件。

相關虛擬碼如下:

// 許可權事件
this.eventMap = {
    clickData: true,
    onEnter: true,
    onLeave: true
}

// 許可權節點
this.nodeMap = {
    outline: true,
    outline2: true,
    earth: true,
    bubbles: true,
    circle: true
}

/**
  * 監聽事件
  */
initMonitor() {
    var gv = this.gv
   var self = this
    var evntFlow = function (e) {
        var event = e.kind
        var tag = e.data && e.data.getTag()

         // 檢查當前事件或者節點是否能夠被執行
         if (!self.eventMap[event] && !self.nodeMap[tag]) return false

         self.nodeEvent(event, tag)
    }

    gv.mi(eventFlow)
}    

只要我們當前要執行的節點符合要求,我們就會把 event (當前執行的事件) 和 tag (節點標籤) 傳給執行函式 nodeEvent 執行**。**這樣就不會浪費資源去處理那些無效的事件或者節點了。

我們接下來來看看 nodeEvent 怎麼處理吧!

相關虛擬碼如下:

/**
 * 氣泡事件
 * @param { string } event 當前事件
 * @param { string } propertyName 當前節點標籤
 */
bubblesEvent(event, propertyName) {
    var dm = this.dm
    var account = dm.getDataByTag('account')
    var currentNode = dm.getDataByTag(propertyName)
    var self = this

    var clickData = function() {
        // 執行清除動作
        self.clearAction()
    }

    var onEnter = function() {
       // do something
    }

    var onLeave = function() {
    // do something
    }

    var allEvent = { clickData, onEnter, onLeave }

    allEvent[event] && allEvent[event]()
}

可以看到,我們可以利用 propertyName(節點標籤) 字串拼接組成一個方法名。比如當前拿到的節點標籤是 bubbles , this[`${ properName }Event`] 之後,拿到就是 this['bubblesEvent'] 這個方法。當然,這個方法是我們事先定義好的。

在具體的節點方法裡面,我們建立了對應的事件函式。根據傳過來的 event 來判斷是否擁有對應的方法。如果有的話執行,否則返回 false 。這樣做的好處是:解耦、結構簡潔、出現問題能夠快速定位。

但是,如果我們仔細想想,我們點選世界地圖和中國地圖的時候,功能都差不多!如果我們可以將他們合併的話,就會方便很多了!!我們來改造一下程式碼。

相關虛擬碼如下:

/**
  * 執行節點事件
  */
nodeEvent(event, propertyName) {
    // 過濾是否有可以合併的事件
    var filterEvents = function(propertyName) {
        var isCombine = false     
        var self = this
        if (['earth', 'china'].includes(propertyName)) {
            self.changeCameraLocaltions(event, propertyName)
            isCombine = true
        }

        return !isCombine
    }
   var eventFun = this[`${propertyName}Event`]
   // 執行對應的節點事件
   filterEvents(propertyName)
   &&
   eventFun  
   &&
   eventFun(event, propertyName)
}

我們事先判斷當前事件是否能合併,如果能的話返回 **false ,**不再執行下面的程式碼,然後執行自己的函式。

這時候,我們就可以通過對應的節點標籤,從 data.js 的 cameraLocations 變數中取到對應的 center、eye 。

相關虛擬碼如下:

/**
 * 移動鏡頭動畫
 * @param { object } config 座標物件
 */
moveCameraAnim(gv, config) {
  var eye = config.eye  var center = config.center  // 如果動畫已經存在,進行清空
   if(globalAnim.moveCameraAnim) {
      globalAnim.moveCameraAnim.stop()    globalAnim.moveCameraAnim = null
   }

   var animConfig = {
     duration: 2e3
   }

   globalAnim.moveCameraAnim = gv.moveCamera(eye, center, animConfig)
}

// 需要改變相機位置
changeCameraLocaltions(event, properName) {
    var config = cameraLocations[properName]

    // 移動相機
    this.moveCameraAnim(this.gv, config)
}

移動鏡頭動畫使用到了 gv 的 moveCamera 方法,該方法接受 3 個引數,eye (相機),**center (目標),animConfig (動畫配置) 。**然後我們把當前動畫返回給 globalAnim 的 moveCameraAnim 屬性,方便我們進行清理。

接下來,就是切換頁面了,這點需要非常小心謹慎。因為一旦沒有把某個屬性清除的話,將會導致記憶體洩漏等問題,效能會越來越慢。將會導致頁面卡死的情況!

所以我們需要一個專門用來清除資料模型的函式 **clearAction 。**我們應該把所有的動畫物件放到一個物件或者陣列中。這樣方便切換頁面的時候清理掉。

相關虛擬碼如下:

/**
 * 清除動作
 */
clearAction(index) {
    var { dm, gv } = this
    var { g3d, d3d } = window

    allListener.mi3d && g3d.umi(allListener.mi3d)
    allListener.mi2d && gv.umi(allListener.mi2d)
    dm.removeScheduleTask(this.schedule)

    dm && dm.clear()
    d3d && d3d.clear()

    window.d3d = null
    window.dm = null

    for (var i in globalAnim) {
        globalAnim[i] && globalAnim[i].pause()
        globalAnim[i] = null
    }

    // 清除對應的 3D 圖紙
    ht.Default.removeHTML(g3d)

    gv.addToDOM()
    ht.Default.xhrLoad(`displays/HT-project_2019/風電/${index}.json`, function (text) {
        let json = ht.Default.parse(text)
        gv.deserialize(json, function(json, dm2, gv2, datas) {
            if (json.title) document.title = json.title

            if (json.a['json.background']) {
                let bgJSON = json.a['json.background']
                if (bgJSON.indexOf('scenes') === 0) {
                    var bgG3d

                    if (g3d) {
                        bgG3d = g3d
                    } else {
                        bgG3d = new ht.graph3d.Graph3dView()
                    }

                    var bgG3dStyle = bgG3d.getView()
                    bgG3dStyle.className = index === 1 ? '' : index === 3 ? 'land' : 'offshore'

                    bgG3d.deserialize(bgJSON, function(json, dm3, gv3, datas) {
                        init3d(dm3, gv3)
                    })

                    bgG3d.addToDOM()
                    gv.addToDOM(bgG3dStyle)
                }
                gv.handleScroll = function () {}
            }

            init2d(dm2, gv2)
        })
    })
}

首先我們需要把 dm(資料模型) 和 gv(圖紙) 清除掉。還要注意:mi(監聽函式)schedule(排程任務) 應該在 dm.clear() 之前 remove。所有的動畫進行 stop() 操作,然後將其值設為 null 。這裡需要注意的是, 執行 stop 之後,會呼叫一次 finishFunc 回撥函式。

當我們的 2D 圖紙裡面包含 3D 背景的情況下,需要判斷是否已經存在了 3D 的例項,如果存在不需要再次建立。有興趣可以瞭解一下 webGL 的應用記憶體洩漏問題。

當進入兩個 3D 場景場景的時候,我們需要一個開場動畫,如開頭效果 gif 圖一樣。所以我們,需要把兩個開場動畫的 center 和 eye 都存到我們已經定義好的 cameraLocations 中。

// 記錄位置
var cameraLocations = {
    earth: {
        eye: [-73, 448, 2225],
        center: [0, 0, 0]
    },

    china: {
        eye: [-91, 476, 916],
        center: [0, 0, 0]
    },

    tsankiang: {
        eye: [35, 241, 593],
        center: [0, 0, 0]
    },

    offshoreStart: {
        eye: [-849, 15390, -482],
        center: [0, 0, 0]

    },

    landStart: {
        eye: [61, 27169, 55],
        center: [0, 0, 0]
    },

    offshoreEnd: {
        eye: [-3912, 241, 834],
        center: [0, 0, 0]
    },

    landEnd: {
        eye: [4096, 4122, -5798],
        center: [1261, 2680, -2181]
    }
}

offshoreStart、offshoreEnd、landStart、landEnd 表示海上和陸上發電場的開始位置和結束位置**。**

我們需要判斷當前載入的是海上發電場還是陸上發電場。我們可以在載入對應圖紙的時候新增 className 。

我們在 clearAction 這個函式已經定義了 index 這個引數,如果點選的是陸地發電場傳的就是數字3,如果是海上發電場的話,就是數字4。

比如我需要載入陸地發電場,那麼就可以通過判斷 g3d.className = index === 3 ? 'land' : 'offshore' 來新增 className 。

然後在 init 裡面進行初始化的判斷。

相關虛擬碼如下:

init() {
    var className = g3d.getView().className
    
    // 執行單獨的事件
    this.selfAnimStart(className)
    this.initData()

    // 監聽事件
    this.monitor()
}

我們拿到對應的 **className ,**傳入相對應的型別並且執行對應的初始化事件,通過我們已經定義好的 moveCameraAnim 函式進行相機的動畫。

相關虛擬碼如下:

/**
  * 不同風電場的開場動畫
  */
selfAnimStart(type) {
    var gv = this.gv
    var { eye, center } = cameraLocations[`${type}End`]
    var config = {
        duration: 3000,
        eye,
        center,
     }

     this.moveCameraAnim(gv, config)
}

總結


這個專案讓我們更加了解了風力發電。不管是風力發電場的地區優勢,還是風機的結構、運轉原理。

做完這個專案,自己得到了很多的成長和感悟。對於技術快速成長的一個好方法就是去不斷的摳細節。專案是一件藝術品,需要不斷對其進行打磨,要做到自己滿意為止。每個細微的點都會影響後面的效能。所以,我們應該以匠人的精神去做任何事。

當然,我也希望一些夥伴能夠勇於探索工業網際網路領域。我們能夠實現的遠遠不止於此。這需要發揮我們的想象力,為這個領域增添更多好玩的、實用的 demo。而且還能學到很多工業領域的知識。

相關文章