1. 程式人生 > >從0開發3D引擎(十):使用領域驅動設計,從最小3D程式中提煉引擎(上)

從0開發3D引擎(十):使用領域驅動設計,從最小3D程式中提煉引擎(上)

[TOC] 大家好,本文使用領域驅動設計的方法,重新設計最小3D程式,識別出“使用者”和“引擎”角色,給出各種設計的檢視。 # 上一篇博文 [從0開發3D引擎(九):實現最小的3D程式-“繪製三角形”](https://www.cnblogs.com/chaogex/p/12234673.html) # 下一篇博文 # 前置知識 [從0開發3D引擎(補充):介紹領域驅動設計](https://www.cnblogs.com/chaogex/p/12408802.html) # 回顧上文 上文獲得了下面的成果: 1、最小3D程式 2、領域驅動設計的通用語言 ## 最小3D程式完整程式碼地址 [Book-Demo-Triangle Github Repo](https://github.com/Wonder-Book/Book-Demo-Triangle) ## 通用語言 ![此處輸入圖片的描述][1] ## 將會在本文解決的不足之處 1、場景邏輯和WebGL API的呼叫邏輯混雜在一起 2、存在重複程式碼: 1)在_init函式的“初始化所有Shader”中有重複的模式 2)在_render中,渲染三個三角形的程式碼非常相似 3)Utils的sendModelUniformData1和sendModelUniformData2有重複的模式 3、_init傳遞給主迴圈的資料過於複雜 # 本文流程 我們根據上文的成果,進行下面的設計: 1、識別最小3D程式的使用者邏輯和引擎邏輯 2、根據使用者邏輯,給出用例圖,用於設計API 3、設計分層架構,給出架構檢視 4、進行領域驅動設計的戰略設計 1)劃分引擎子域和限界上下文 2)給出限界上下文對映圖 5、進行領域驅動設計的戰術設計 1)識別領域概念 2)建立領域模型,給出領域檢視 6、設計資料,給出資料檢視 7、根據用例圖,設計分層架構的API層 8、根據API層的設計,設計分層架構的應用服務層 9、進行一些細節的設計: 1)使用Result處理錯誤 2)使用“Discriminated Union型別”來加強值物件的值型別約束 10、基本的優化 # 解釋本文使用的領域驅動設計的一些概念 - 持久化資料 因為我們並沒有使用資料庫,不需要離線儲存,所以本文提到的持久化資料是指:從程式啟動到程式結束時,將資料儲存到記憶體中 - “PO”和“XXX PO”(XXX為聚合根名,如Scene) “PO”是指整個PO; “XXX PO”是指PO的XXX(聚合根)欄位的PO資料。 如: ```re //定義聚合根Scene的PO的型別 type scene = { ... }; //定義PO的型別 type po = { scene }; “PO”的型別為po,“Scene PO”的型別為scene ``` - “XXX DO”(XXX為聚合根名,如Scene) “XXX DO”是指XXX(聚合根)的DO資料。 如: ```re module SceneEntity = { //定義聚合根Scene的DO的型別 type t = { ... }; }; “Scene DO”的型別為SceneEntity.t ``` # 本文的領域驅動設計選型 - 使用分層架構 - 領域模型(領域服務、實體、值物件)使用貧血模型 這只是目前的選型,在後面的文章中我們會修改它們。 # 設計 ## 引擎名 TinyWonder 因為本系列開發的引擎的素材來自於[Wonder.js](https://github.com/Wonder-Technology/Wonder.js),只有最小化的功能,所以叫TinyWonder ## 識別最小3D程式的頂層包含的使用者邏輯和引擎邏輯 從頂層來看,包含三個部分的邏輯:建立場景、初始化、主迴圈 我們依次識別它們的使用者邏輯和引擎邏輯: 1、建立場景 **使用者邏輯** - 準備場景資料 場景資料包括canvas的id、三個三角形的資料等 - 呼叫API,儲存某個場景資料 - 呼叫API,獲得某個場景資料 **引擎邏輯** - 儲存某個場景資料 - 獲得某個場景資料 2、初始化 **使用者邏輯** - 呼叫API,進行初始化 **引擎邏輯** - 實現初始化 3、主迴圈 **使用者邏輯** - 呼叫API,開啟主迴圈 **引擎邏輯** - 實現主迴圈 ## 根據對最小3D程式的頂層的分析,用虛擬碼初步設計index.html index.html ```re /* “User.”表示這是使用者要實現的函式 “EngineJsAPI.”表示這是引擎提供的API函式 使用"xxx()"代表某個函式 */ //由使用者實現 module User = { let prepareSceneData = () => { let (canvasId, ...) = ... ... (canvasId, ...) }; ... }; let (canvasId, ...) = User.prepareSceneData(); //儲存某個場景資料到引擎中 EngineJsAPI.setXXXSceneData(canvasId, ...); EngineJsAPI.進行初始化(); EngineJsAPI.開啟主迴圈(); ``` ## 識別最小3D程式的初始化包含的使用者邏輯和引擎邏輯 初始化對應的通用語言為: ![此處輸入圖片的描述][2] 最小3D程式的_init函式負責初始化 現在依次分析初始化的每個步驟對應的程式碼: 1、獲得WebGL上下文 相關程式碼為: ```re let canvas = DomExtend.querySelector(DomExtend.document, "#webgl"); let gl = WebGL1.getWebGL1Context( canvas, { "alpha": true, "depth": true, "stencil": false, "antialias": true, "premultipliedAlpha": true, "preserveDrawingBuffer": false, }: WebGL1.contextConfigJsObj, ); ``` **使用者邏輯** 我們可以先識別出下面的使用者邏輯: - 準備canvas的id - 呼叫API,傳入canvas的id - 準備webgl上下文的配置項 使用者需要傳入webgl上下文的配置項到引擎中。 我們進行相關的思考: 引擎應該增加一個傳入配置項的API嗎? 配置項應該儲存到引擎中嗎? 考慮到: - 該配置項只被使用一次,即在“獲得webgl上下文”時才需要使用配置項 - “獲得webgl上下文”是在“初始化”的時候進行 所以引擎不需要增加API,也不需要儲存配置項,而是在“進行初始化”的API中傳入“配置項”,使用一次後即丟棄。 **引擎邏輯** - 獲得canvas - 雖然不用儲存配置項,但是要根據配置項和canvas,儲存從canvas獲得的webgl的上下文 2、初始化所有Shader 相關程式碼為: ```re let program1 = gl |> WebGL1.createProgram |> Utils.initShader(GLSL.vs1, GLSL.fs1, gl); let program2 = gl |> WebGL1.createProgram |> Utils.initShader(GLSL.vs2, GLSL.fs2, gl); ``` **使用者邏輯** 使用者需要將兩組GLSL傳入引擎,並且把GLSL組與三角形關聯起來。 我們進行相關的思考: 如何使GLSL組與三角形關聯? 我們看下相關的通用語言: ![此處輸入圖片的描述][3] 三角形與Shader一一對應,而Shader又與GLSL組一一對應。 因此,我們可以在三角形中增加資料:Shader名稱(型別為string),從而使三角形通過Shader名稱與GLSL組一一關聯。 更新後的三角形通用語言為: ![此處輸入圖片的描述][4] 根據以上的分析,我們識別出下面的使用者邏輯: - 準備兩個Shader名稱 - 準備兩組GLSL - 呼叫API,傳入一個三角形的Shader名稱 使用者需要呼叫該API三次,從而把所有三角形的Shader名稱都傳入引擎 - 呼叫API,傳入一個Shader名稱和關聯的GLSL組 使用者需要呼叫該API兩次,從而把所有Shader的Shader名稱和GLSL組都傳入引擎 **引擎邏輯** 我們現在來思考如何解決下面的不足之處: > 存在重複程式碼: 1)在_init函式的“初始化所有Shader”中有重複的模式 解決方案: 1、獲得所有Shader的Shader名稱和GLSL組集合 2、遍歷這個集合: 1)建立Program 2)初始化Shader 這樣的話,就只需要寫一份“初始化每個Shader”的程式碼了,消除了重複。 根據以上的分析,我們識別出下面的引擎邏輯: - 獲得所有Shader的Shader名稱和GLSL組集合 - 遍歷這個集合 - 建立Program - 初始化Shader 3、初始化場景 相關程式碼為: ```re let (vertices1, indices1) = Utils.createTriangleVertexData(); let (vertices2, indices2) = Utils.createTriangleVertexData(); let (vertices3, indices3) = Utils.createTriangleVertexData(); let (vertexBuffer1, indexBuffer1) = Utils.initVertexBuffers((vertices1, indices1), gl); let (vertexBuffer2, indexBuffer2) = Utils.initVertexBuffers((vertices2, indices2), gl); let (vertexBuffer3, indexBuffer3) = Utils.initVertexBuffers((vertices3, indices3), gl); let (position1, position2, position3) = ( (0.75, 0., 0.), ((-0.), 0., 0.5), ((-0.5), 0., (-2.)), ); let (color1, (color2_1, color2_2), color3) = ( (1., 0., 0.), ((0., 0.8, 0.), (0., 0.5, 0.)), (0., 0., 1.), ); let ((eyeX, eyeY, eyeZ), (centerX, centerY, centerZ), (upX, upY, upZ)) = ( (0., 0.0, 5.), (0., 0., (-100.)), (0., 1., 0.), ); let (near, far, fovy, aspect) = ( 1., 100., 30., (canvas##width |> Js.Int.toFloat) /. (canvas##height |> Js.Int.toFloat), ); ``` **使用者邏輯** - 呼叫API,準備三個三角形的頂點資料 因為每個三角形的頂點資料都一樣,所以應該由引擎負責建立三角形的頂點資料,然後由使用者呼叫三次API來準備三個三角形的頂點資料 - 呼叫API,傳入三個三角形的頂點資料 - 準備三個三角形的位置資料 - 準備三個三角形的顏色資料 - 準備相機資料 準備view matrix需要的eye、center、up向量和projection matrix需要的near、far、fovy、aspect - 呼叫API,傳入相機資料 **引擎邏輯** - 建立三角形的頂點資料 - 儲存三個三角形的頂點資料 - 儲存三個三角形的位置資料 - 儲存三個三角形的顏色資料 - 建立和初始化三個三角形的VBO - 儲存相機資料 儲存eye、center、up向量和near、far、fovy、aspect ## 識別最小3D程式的主迴圈包含的使用者邏輯和引擎邏輯 主迴圈對應的通用語言為: ![此處輸入圖片的描述][5] 對應最小3D程式的_loop函式對應主迴圈,現在依次分析主迴圈的每個步驟對應的程式碼: 1、開啟主迴圈 相關程式碼為: ```re let rec _loop = data => DomExtend.requestAnimationFrame((time: float) => { _loopBody(data); _loop(data) |> ignore; }); ``` **使用者邏輯** 無 **引擎邏輯** - 呼叫requestAnimationFrame開啟主迴圈 現在進入_loopBody函式: 2、設定清空顏色緩衝時的顏色值 相關程式碼為: ```re let _clearColor = ((gl, sceneData) as data) => { WebGL1.clearColor(0., 0., 0., 1., gl); data; }; let _loopBody = data => { data |> ... |> _clearColor |> ... }; ``` **使用者邏輯** - 準備清空顏色緩衝時的顏色值 - 呼叫API,傳入清空顏色緩衝時的顏色值 **引擎邏輯** - 儲存清空顏色緩衝時的顏色值 - 設定清空顏色緩衝時的顏色值 3、清空畫布 相關程式碼為: ```re let _clearCanvas = ((gl, sceneData) as data) => { WebGL1.clear( WebGL1.getColorBufferBit(gl) lor WebGL1.getDepthBufferBit(gl), gl, ); data; }; let _loopBody = data => { data |> ... |> _clearCanvas |> ... }; ``` **使用者邏輯** 無 **引擎邏輯** - 清空畫布 4、渲染 相關程式碼為: ```re let _loopBody = data => { data |> ... |> _render; }; ``` **使用者邏輯** 無 **引擎邏輯** - 渲染 現在進入_render函式,我們來分析“渲染”的每個步驟對應的程式碼: 1)設定WebGL狀態 _render函式中的相關程式碼為: ```re WebGL1.enable(WebGL1.getDepthTest(gl), gl); WebGL1.enable(WebGL1.getCullFace(gl), gl); WebGL1.cullFace(WebGL1.getBack(gl), gl); ``` **使用者邏輯** - 無 **引擎邏輯** - 設定WebGL狀態 2)計算view matrix和projection matrix _render函式中的相關程式碼為: ```re let vMatrix = Matrix.createIdentityMatrix() |> Matrix.setLookAt( (eyeX, eyeY, eyeZ), (centerX, centerY, centerZ), (upX, upY, upZ), ); let pMatrix = Matrix.createIdentityMatrix() |> Matrix.buildPerspective((fovy, aspect, near, far)); ``` **使用者邏輯** 無 **引擎邏輯** - 計算view matrix - 計算projection matrix 3)計算三個三角形的model matrix _render函式中的相關程式碼為: ```re let mMatrix1 = Matrix.createIdentityMatrix() |> Matrix.setTranslation(position1); let mMatrix2 = Matrix.createIdentityMatrix() |> Matrix.setTranslation(position2); let mMatrix3 = Matrix.createIdentityMatrix() |> Matrix.setTranslation(position3); ``` **使用者邏輯** 無 **引擎邏輯** - 計算三個三角形的model matrix 4)渲染第一個三角形 _render函式中的相關程式碼為: ```re WebGL1.useProgram(program1, gl); Utils.sendAttributeData(vertexBuffer1, program1, gl); Utils.sendCameraUniformData((vMatrix, pMatrix), program1, gl); Utils.sendModelUniformData1((mMatrix1, color1), program1, gl); WebGL1.bindBuffer(WebGL1.getElementArrayBuffer(gl), indexBuffer1, gl); WebGL1.drawElements( WebGL1.getTriangles(gl), indices1 |> Js.Typed_array.Uint16Array.length, WebGL1.getUnsignedShort(gl), 0, gl, ); ``` **使用者邏輯** 無 **引擎邏輯** - 根據第一個三角形的Shader名稱,獲得關聯的Program - 渲染第一個三角形 - 使用對應的Program - 傳遞三角形的頂點資料 - 傳遞view matrix和projection matrix - 傳遞三角形的model matrix - 傳遞三角形的顏色資料 - 繪製三角形 - 根據indices計算頂點個數,作為drawElements的第二個形參 2)渲染第二個和第三個三角形 _render函式中的相關程式碼為: ```re WebGL1.useProgram(program2, gl); Utils.sendAttributeData(vertexBuffer2, program2, gl); Utils.sendCameraUniformData((vMatrix, pMatrix), program2, gl); Utils.sendModelUniformData2((mMatrix2, color2_1, color2_2), program2, gl); WebGL1.bindBuffer(WebGL1.getElementArrayBuffer(gl), indexBuffer2, gl); WebGL1.drawElements( WebGL1.getTriangles(gl), indices2 |> Js.Typed_array.Uint16Array.length, WebGL1.getUnsignedShort(gl), 0, gl, ); WebGL1.useProgram(program1, gl); Utils.sendAttributeData(vertexBuffer3, program1, gl); Utils.sendCameraUniformData((vMatrix, pMatrix), program1, gl); Utils.sendModelUniformData1((mMatrix3, color3), program1, gl); WebGL1.bindBuffer(WebGL1.getElementArrayBuffer(gl), indexBuffer3, gl); WebGL1.drawElements( WebGL1.getTriangles(gl), indices3 |> Js.Typed_array.Uint16Array.length, WebGL1.getUnsignedShort(gl), 0, gl, ); ``` **使用者邏輯** 與“渲染第一個三角形”的使用者邏輯一樣,只是將第一個三角形的資料換成第二個和第三個三角形的資料 **引擎邏輯** 與“渲染第一個三角形”的引擎邏輯一樣,只是將第一個三角形的資料換成第二個和第三個三角形的資料 ## 根據使用者邏輯,給出用例圖 識別出兩個角色: - 引擎 - index.html index.html頁面是引擎的使用者 我們把使用者邏輯中需要使用者實現的邏輯移到角色“index.html”中; 把使用者邏輯中需要呼叫API實現的邏輯作為用例,移到角色“引擎”中。 得到的用例圖如下所示: ![此處輸入圖片的描述][6] ## 設計架構,給出架構檢視 我們使用四層的分層架構,架構檢視如下所示: ![此處輸入圖片的描述][7] 不允許跨層訪問。 對於“API層”和“應用服務層”,我們會在給出領域檢視後,詳細設計它們。 我們加入了“倉庫”,使“實體”只能通過“倉庫”來操作“資料”,隔離“資料”和“實體”。 只有“實體”負責持久化資料,所以只有“實體”依賴“倉庫”,“值物件”和“領域服務”都不應該依賴“倉庫”。 之所以“倉庫”依賴了“領域服務”、“實體”、“值物件”,是因為“倉庫”需要呼叫它們的函式,實現“資料”的PO和領域層的DO之間的轉換。 對於“倉庫”、“資料”、PO、DO,我們會在後面的“設計資料”中詳細分析。 ### 分析“基礎設施層”的“外部” “外部”負責與引擎的外部互動。 它包含兩個部分: - Js庫 使用FFI封裝引擎呼叫的Js庫。 - 外部物件 使用FFI定義外部物件,如: 最小3D程式的DomExtend.re可以放在這裡,因為它依賴了“window”這個外部物件; Utils.re的error函式也可以放在這裡,因為它們依賴了“js異常”這個外部物件。 ## 劃分引擎子域和限界上下文 如下圖所示: ![此處輸入圖片的描述][8] ## 給出限界上下文對映圖 如下圖所示: ![此處輸入圖片的描述][9] 其中: - “U”為上游,“D”為下游 下游依賴上游 - “C”為遵奉者 - “CSD”為客戶方——供應方開發 - “OHS”為開放主機服務 - “PL”為釋出語言 - “ACL”為防腐層 上下文關係的介紹詳見[上下文對映圖](https://www.cnblogs.com/chaogex/p/12408802.html#%E4%B8%8A%E4%B8%8B%E6%96%87%E6%98%A0%E5%B0%84%E5%9B%BE) 現在我們來分析下防腐層(ACL)的設計,其中相關的領域模型會在後面的“領域檢視”中給出。 ### “初始化所有Shader”限界上下文的防腐設計 1、“著色器”限界上下文提供著色器的DO資料 2、“初始化所有Shader”限界上下文的領域服務BuildInitShaderData作為防腐層,將著色器DO資料轉換為值物件InitShader 3、“初始化所有Shader”限界上下文的領域服務InitShader遍歷值物件InitShader,初始化每個Shader 通過這樣的設計,隔離了領域服務InitShader和“著色器”限界上下文。 #### 設計值物件InitShader 根據識別的引擎邏輯,可以得知值物件InitShader的值是所有Shader的Shader名稱和GLSL組集合,因此我們可以給出值物件InitShader的型別定義: ```re type singleInitShader = { shaderId: string, vs: string, fs: string, }; //值物件InitShader型別定義 type initShader = list(singleInitShader); ``` ### “渲染”限界上下文的防腐設計 1、“場景圖”限界上下文提供場景圖的DO資料 2、“渲染”限界上下文的領域服務BuildRenderData作為防腐層,將場景圖DO資料轉換為值物件Render 3、“渲染”限界上下文的領域服務Render遍歷值物件Render,渲染場景中每個三角形 通過這樣的設計,隔離了領域服務Render和“場景圖”限界上下文。 #### 設計值物件Render 最小3D程式的_render函式的引數是渲染需要的資料,這裡稱之為“渲染資料”。 最小3D程式的_render函式的引數如下: ```re let _render = ( ( gl, ( (program1, program2), (indices1, indices2, indices3), (vertexBuffer1, indexBuffer1), (vertexBuffer2, indexBuffer2), (vertexBuffer3, indexBuffer3), (position1, position2, position3), (color1, (color2_1, color2_2), color3), ( ( (eyeX, eyeY, eyeZ), (centerX, centerY, centerZ), (upX, upY, upZ), ), (near, far, fovy, aspect), ), ), ), ) => { ... }; ``` 現在,我們結合識別的引擎邏輯,對渲染資料進行抽象,提煉出值物件Render,並給出值物件Render的型別定義。 因為渲染資料包含三個部分的資料:WebGL的上下文gl、場景中唯一的相機資料、場景中所有三角形的資料,所以值物件Render也應該包含這三個部分的資料:WebGL的上下文gl、相機資料、三角形資料 可以直接把渲染資料中的WebGL的上下文gl放到值物件Render中 對於渲染資料中的“場景中唯一的相機資料”: ```re ( ( (eyeX, eyeY, eyeZ), (centerX, centerY, centerZ), (upX, upY, upZ), ), (near, far, fovy, aspect), ), ``` 根據識別的引擎邏輯,我們知道在渲染場景中所有的三角形前,需要根據這些渲染資料計算一個view matrix和一個projection matrix。因為值物件Render是為渲染所有三角形服務的,所以值物件Render的相機資料應該為一個view matrix和一個projection matrix 對於下面的渲染資料: ```re (position1, position2, position3), ``` 根據識別的引擎邏輯,我們知道在渲染場景中所有的三角形前,需要根據這些渲染資料計算每個三角形的model matrix,所以值物件Render的三角形資料應該包含每個三角形的model matrix 對於下面的渲染資料: ```re (indices1, indices2, indices3), ``` 根據識別的引擎邏輯,我們知道在呼叫drawElements繪製每個三角形時,需要根據這些渲染資料計算頂點個數,作為drawElements的第二個形參,所以值物件Render的三角形資料應該包含每個三角形的頂點個數 對於下面的渲染資料: ```re (program1, program2), (vertexBuffer1, indexBuffer1), (vertexBuffer2, indexBuffer2), (vertexBuffer3, indexBuffer3), ``` 它們可以作為值物件Render的三角形資料。經過抽象後,值物件Render的三角形資料應該包含每個三角形關聯的program、每個三角形的VBO資料(一個vertex buffer和一個index buffer) 對於下面的渲染資料(三個三角形的顏色資料),我們需要從中設計出值物件Render的三角形資料包含的顏色資料: ```re (color1, (color2_1, color2_2), color3), ``` 我們需要將其統一為一個數據結構,才能作為值物件Render的顏色資料。 我們回顧下將會在本文解決的不足之處: > 2、存在重複程式碼: ... 2)在_render中,渲染三個三角形的程式碼非常相似 3)Utils的sendModelUniformData1和sendModelUniformData2有重複的模式 這兩處的重複跟顏色的資料結構不統一是有關係的。 我們來看下最小3D程式中相關的程式碼: Main.re ```re let _render = (...) => { ... //渲染第一個三角形 ... Utils.sendModelUniformData1((mMatrix1, color1), program1, gl); ... //渲染第二個三角形 ... Utils.sendModelUniformData2((mMatrix2, color2_1, color2_2), program2, gl); ... //渲染第三個三角形 ... Utils.sendModelUniformData1((mMatrix3, color3), program1, gl); ... }; ``` Utils.re ```re let sendModelUniformData1 = ((mMatrix, color), program, gl) => { ... let colorLocation = _unsafeGetUniformLocation(program, "u_color0", gl); ... _sendColorData(color, gl, colorLocation); }; let sendModelUniformData2 = ((mMatrix, color1, color2), program, gl) => { ... let color1Location = _unsafeGetUniformLocation(program, "u_color0", gl); let color2Location = _unsafeGetUniformLocation(program, "u_color1", gl); ... _sendColorData(color1, gl, color1Location); _sendColorData(color2, gl, color2Location); }; ``` 通過仔細分析這些相關的程式碼,我們可以發現這兩處的重複其實都由同一個原因造成的: 由於第一個和第三個三角形的顏色資料與第二個三角形的顏色資料不同,需要呼叫對應的sendModelUniformData1或sendModelUniformData2方法來傳遞對應三角形的顏色資料。 **解決“Utils的sendModelUniformData1和sendModelUniformData2有重複的模式”** 那是否可以把所有三角形的顏色資料統一用一個數據結構來儲存,然後在渲染三角形->傳遞三角形的顏色資料時,遍歷該資料結構,只用一個函式(而不是兩個函式:sendModelUniformData1、sendModelUniformData2)傳遞對應的顏色資料,從而解決該重複呢? 我們來分析下三個三角形的顏色資料: 第一個和第三個三角形只有一個顏色資料,型別為(float, float, float); 第二個三角形有兩個顏色資料,它們的型別也為(float, float, float)。 根據分析,我們作出下面的設計: 可以使用列表來儲存一個三角形所有的顏色資料,它的型別為list((float,float,float)); 在傳遞該三角形的顏色資料時,遍歷列表,傳遞每個顏色資料。 相關虛擬碼如下: ```re let sendModelUniformData = ((mMatrix, colors: list((float,float,float))), program, gl) => { colors |> List.iteri((index, (r, g, b)) => { let colorLocation = _unsafeGetUniformLocation(program, {j|u_color$index|j}, gl); WebGL1.uniform3f(colorLocation, r, g, b, gl); }); ... }; ``` 這樣我們就解決了該重複。 **解決“在_render中,渲染三個三角形的程式碼非常相似”** 通過“統一用一種資料結構來儲存顏色資料”,就可以構造出值物件Render,從而解決該重複了: 我們不再需要寫三段程式碼來渲染三個三角形了,而是隻寫一段“渲染每個三角形”的程式碼,然後在遍歷值物件Render時執行它。 相關虛擬碼如下: ```re let 渲染每個三角形 = (每個三角形的資料) => {...}; let _render = (...) => { ... 構造值物件Render(場景圖資料) |> 遍歷值物件Render的三角形資料((每個三角形的資料) => { 渲染每個三角形(每個三角形的資料) }); ... }; ``` ##### 給出值物件Render的型別定義 通過前面對渲染資料的分析,可以給出值物件Render的型別定義: ```re type triangle = { mMatrix: Js.Typed_array.Float32Array.t, vertexBuffer: WebGL1.buffer, indexBuffer: WebGL1.buffer, indexCount: int, //使用統一的資料結構 colors: list((float, float, float)), program: WebGL1.program, }; type triangles = list(triangle); type camera = { vMatrix: Js.Typed_array.Float32Array.t, pMatrix: Js.Typed_array.Float32Array.t, }; type gl = WebGL1.webgl1Context; //值物件Render型別定義 type render = (gl, camera, triangles); ``` ## 識別領域概念 識別出新的領域概念: - Transform 我們識別出“Transform”的概念,用它來在座標系中定位三角形。 Transform的資料包括三角形的位置、旋轉和縮放。在當前場景中,Transform資料 = 三角形的位置 - Geometry 我們識別出“Geometry”的概念,用它來表達三角形的形狀。 Geometry的資料包括三角形的頂點資料和VBO。在當前場景中,Geometry資料 = 三角形的Vertices、Indices和對應的VBO - Material 我們識別出“Material”的概念,用它來表達三角形的材質。 Material的資料包括三角形的著色器、顏色、紋理、光照。在當前場景中,Material資料 = 三角形的Shader名稱 + 三角形的顏色 ## 建立領域模型,給出領域檢視 領域檢視如下所示,圖中包含了領域模型之間的所有聚合、組合關係,以及領域模型之間的主要依賴關係 ![此處輸入圖片的描述][10] ## 設計資料 ### 分層資料檢視 如下圖所示: ![此處輸入圖片的描述][11] ### 設計PO Container PO Container作為一個容器,負責儲存PO到記憶體中。 PO Container應該為一個全域性Record,有一個可變欄位po,用於儲存PO 相關的設計為: ```re type poContainer = { mutable po }; let poContainer = { po: 建立PO() }; ``` 這裡有兩個壞味道: - poContainer為全域性變數 這是為了讓poContainer在程式啟動到終止期間,一直存在於記憶體中 - 使用了可變欄位po 這是為了在設定PO到poContainer中時,讓poContainer在記憶體中始終只有一份 我們應該儘量使用區域性變數和不可變資料/不可變操作,消除共享的狀態。但有時候壞味道不可避免,因此我們使用下面的策略來處理壞味道: - 把壞味道集中和隔離到一個可控的範圍 - 使用容器來封裝副作用 如函式內部發生錯誤時,可以用容器來包裝錯誤資訊,返回給函式外部,在外部的某處(可控的範圍)集中處理錯誤。詳見後面的“使用Result處理錯誤” ### 設計PO 我們設計如下: - 用Record作為PO的資料結構 - PO的欄位對應聚合根的資料 - PO是不可變資料 相關的設計為: ```re type po = { //各個聚合根的資料 canvas, shaderManager, scene, context, vboManager }; ``` 因為現在資訊不夠,所以不設計聚合根的具體資料,留到實現時再設計它們。 ### 設計容器管理 容器管理負責讀/寫PO Container的PO,相關設計如下: ```re type getPO = unit => po; type setPO = po => unit; ``` ### 設計倉庫 #### 職責 - 將來自領域層的DO轉換為PO,設定到PO Container中 - 從PO Container中獲得PO,轉換為DO傳遞給領域層 #### 虛擬碼和型別簽名 ```re module Repo = { //從PO中獲得ShaderManager PO,轉成ShaderManager DO,返回給領域層 type getShaderManager = unit => shaderManager; //轉換來自領域層的ShaderManager DO為ShaderManager PO,設定到PO中 type setShaderManager = shaderManager => unit; type getCanvas = unit => canvas; type setCanvas = canvas => unit; type getScene = unit => scene; type setScene = scene => unit; type getVBOManager = unit => vboManager; type setVBOManager = vboManager => unit; type getContext = unit => context; type setContext = context => unit; }; module CreateRepo = { //建立各個聚合根的PO資料,如建立ShaderManager PO let create = () => { shaderManager: ..., ... }; }; module ShaderManagerRepo = { //從PO中獲得ShaderManager PO的某個欄位,轉成DO,返回給領域層 type getXXX = po => xxx; //轉換來自領域層的ShaderManager DO的某個欄位為ShaderManager PO的對應欄位,設定到PO中 type setXXX = (...) => unit; }; module CanvasRepo = { type getXXX = unit => xxx; type setXXX = (...) => unit; }; module SceneRepo = { type getXXX = unit => xxx; type setXXX = (...) => unit; }; module VBOManagerRepo = { type getXXX = unit => xxx; type setXXX = (...) => unit; }; module ContextRepo = { type getXXX = unit => xxx; type setXXX = (...) => unit; }; ``` ## 設計API層 ### 職責 - 將index.html輸入的VO轉換為DTO,傳遞給應用服務層 - 將應用服務層輸出的DTO轉換為VO,返回給使用者index.html ### API層的使用者的特點 使用者為index.html頁面,它只知道javascript,不知道Reason ### 引擎API的設計原則 我們根據使用者的特點,決定設計原則: - 應該對使用者隱藏API層下面的層級 如: 使用者不應該知道基礎設施層的“資料”的存在。 - 應該對使用者隱藏實現的細節 如: 使用者需要一個API來獲得canvas,而引擎API通過“非純”操作來獲得canvas並返回給使用者。 使用者不需要知道是怎樣獲得canvas的,所以API的名稱應該為getCanvas,而不應該為unsafeGetCanvas(在引擎中,如果我們通過“非純”操作獲得了某個值,則稱該操作為unsafe) - 輸入和輸出應該為VO,而VO的型別為javascript的資料型別 - 應該對使用者隱藏Reason語言的語法 如: 不應該對使用者暴露Reason語言的Record等資料結構,但可以對使用者暴露Reason語言的Tuple,因為它與javascript的陣列型別相同 - 應該對使用者隱藏Reason語言的型別 如: API的輸入引數和輸出結果應該為javascript的資料型別,不能為Reason獨有的型別 ( Reason的string,int等型別與javascript的資料型別相同,可以作為API的輸入引數和輸出結果; 但是Reason的[Discriminated Union型別](https://www.cnblogs.com/chaogex/p/12172930.html#discriminated-union%E7%B1%BB%E5%9E%8B)、[抽象型別](https://www.cnblogs.com/chaogex/p/12172930.html#%E6%8A%BD%E8%B1%A1%E7%B1%BB%E5%9E%8B)等型別是Reason獨有的,不能作為API的輸入引數和輸出結果。 ) ### 劃分API模組,設計具體的API 首先根據用例圖的用例,劃分API模組; 然後根據API的設計原則,在對應模組中設計具體的API,給出API的型別簽名。 API模組及其API的設計為: ```re module DirectorJsAPI = { //WebGL1.contextConfigJsObj是webgl上下文配置項的型別 type init = WebGL1.contextConfigJsObj => unit; type start = unit => unit; }; module CanvasJsAPI = { type canvasId = string; type setCanvasById = canvasId => unit; }; module ShaderJsAPI = { type shaderName = string; type vs = string; type fs = string; type addGLSL = (shaderName, (vs, fs)) => unit; }; module SceneJsAPI = { type vertices = Js.Typed_array.Float32Array.t; type indices = Js.Typed_array.Uint16Array.t; type createTriangleVertexData = unit => (vertices, indices); //因為“傳入一個三角形的位置資料”、“傳入一個三角形的頂點資料”、“傳入一個三角形的Shader名稱”、“傳入一個三角形的顏色資料”都屬於傳入三角形的資料,所以應該只用一個API接收三角形的這些資料,這些資料應該分成三部分:Transform資料、Geometry資料和Material資料。API負責在場景中加入一個三角形。 type position = (float, float, float); type vertices = Js.Typed_array.Float32Array.t; type indices = Js.Typed_array.Uint16Array.t; type shaderName = string; type color3 = (float, float, float); type addTriangle = (position, (vertices, indices), (shaderName, array(color3))) => unit; type eye = (float, float, float); type center = (float, float, float); type up = (float, float, float); type viewMatrixData = (eye, center, up); type near = float; type far = float; type fovy = float; type aspect = float; type projectionMatrixData = (near, far, fovy, aspect); //函式名為“set”而不是“add”的原因是:場景中只有一個相機,因此不需要加入操作,只需要設定唯一的相機 type setCamera = (viewMatrixData, projectionMatrixData) => unit; }; module GraphicsJsAPI = { type color4 = (float, float, float, float); type setClearColor = color4 => unit; }; ``` ## 設計應用服務層 ### 職責 - 將API層輸入的DTO轉換為DO,傳遞給領域層 - 將領域層輸出的DO轉換為DTO,返回給API層 - 處理錯誤 ### 設計應用服務 我們進行下面的設計: - API層模組與應用服務層的應用服務模組一一對應 - API與應用服務的函式一一對應 目前來看,VO與DTO基本相同。 應用服務模組及其函式設計為: ```re module DirectorApService = { type init = WebGL1.contextConfigJsObj => unit; type start = unit => unit; }; module CanvasApService = { type canvasId = string; type setCanvasById = canvasId => unit; }; module ShaderApService = { type shaderName = string; type vs = string; type fs = string; type addGLSL = (shaderName, (vs, fs)) => unit; }; module SceneApService = { type vertices = Js.Typed_array.Float32Array.t; type indices = Js.Typed_array.Uint16Array.t; type createTriangleVertexData = unit => (vertices, indices); type position = (float, float, float); type vertices = Js.Typed_array.Float32Array.t; type indices = Js.Typed_array.Uint16Array.t; type shaderName = string; type color3 = (float, float, float); //注意:DTO(這個函式的引數)與VO(Scene API的addTriangle函式的引數)有區別:VO的顏色資料型別為array(color3),而DTO的顏色資料型別為list(color3) type addTriangle = (position, (vertices, indices), (shaderName, list(color3))) => unit; type eye = (float, float, float); type center = (float, float, float); type up = (float, float, float); type viewMatrixData = (eye, center, up); type near = float; type far = float; type fovy = float; type aspect = float; type projectionMatrixData = (near, far, fovy, aspect); type setCamera = (viewMatrixData, projectionMatrixData) => unit; }; module GraphicsApService = { type color4 = (float, float, float, float); type setClearColor = color4 => unit; }; ``` ## 使用Result處理錯誤 我們在[從0開發3D引擎(五):函數語言程式設計及其在引擎中的應用](https://www.cnblogs.com/chaogex/p/12172930.html#%E4%BD%BF%E7%94%A8result)中介紹了“使用Result來處理錯誤”,它相比“丟擲異常”的錯誤處理方式,有很多優點。 我們在引擎中主要使用Result來處理錯誤。但是在後面的“優化”中,我們可以看到為了優化,引擎也使用了“丟擲異常”的錯誤處理方式。 ## 使用“Discriminated Union型別”來加強值物件的值型別約束 我們以值物件Matrix為例,來看下如何加強值物件的值型別約束,從而在編譯檢查時確保型別正確: Matrix的值型別為Js.Typed_array.Float32Array.t,這樣的型別設計有個缺點:不能與其它Js.Typed_array.Float32Array.t型別的變數區分開。 因此,在Matrix中可以使用[Discriminated Union型別](https://www.cnblogs.com/chaogex/p/12172930.html#discriminated-union%E7%B1%BB%E5%9E%8B)來定義“Matrix”型別: ```re type t = | Matrix(Js.Typed_array.Float32Array.t); ``` 這樣就能解決該缺點了。 ## 優化 我們在效能熱點處進行下面的優化: - 處理錯誤優化 因為使用“丟擲異常”的方式處理錯誤不需要操作容器Result,效能更好,所以在效能熱點處: 使用“丟擲異常”的方式處理錯誤,然後在上一層使用Result.tryCatch將異常轉換為Result 在其它地方: 直接用Result包裝錯誤資訊 - Discriminated Union型別優化 因為操作“Discriminated Union型別”需要操作容器,效能較差,所以在效能熱點處: 1、在效能熱點開始前,通過一次遍歷操作,將所有相關的值物件的值從“Discriminated Union型別”中取出來。其中取出的值是primitive型別,即int、string等沒有用容器包裹的原始型別 2、在效能熱點處操作primtive型別的值 3、在效能熱點結束後,通過一次遍歷操作,將更新後的primitive型別的值寫到“Discriminated Union型別”中 哪些地方屬於效能熱點呢? 我們需要進行benchmark測試來確定性能熱點,不過一般來說下面的場景屬於效能熱點的概率比較大: - 遍歷數量大的集合 如遍歷場景中所有的三角形,因為通常場景有至少上千個模型。 - 雖然遍歷數量小的集合,但每次遍歷的時間或記憶體開銷大 如遍歷場景中所有的Shader,因為通常場景有隻幾十個到幾百個Shader,數量不是很多,但是在每次遍歷時會初始化Shader,造成較大的時間開銷。 具體來說,目前引擎的適用於此處提出的優化的效能熱點為: - 初始化所有Shader時,優化“遍歷和初始化每個Shader” 優化的虛擬碼為: ```re let 初始化所有Shader = (...) => { ... //著色器資料中有“Discriminated Union”型別的資料,而構造後的值物件InitShader的值均為primitive型別 構造為值物件InitShader(著色器資料) |> //使用Result.tryCatch將異常轉換為Result Result.tryCatch((值物件InitShader) => { //使用“丟擲異常”的方式處理錯誤 根據值物件InitShader,初始化每個Shader }); //因為值物件InitShader是隻讀資料,所以不需要將值物件InitShader更新到著色器資料中 }; ``` - 渲染時,優化“遍歷和渲染每個三角形” 優化的虛擬碼為: ```re let 渲染 = (...) => { ... //場景圖資料中有“Discriminated Union”型別的資料,而構造後的值物件Render的值均為primitive型別 構造值物件Render(場景圖資料) |> //使用Result.tryCatch將異常轉換為Result Result.tryCatch((值物件Render) => { //使用“丟擲異常”的方式處理錯誤 根據值物件Render,渲染每個三角形 }); //因為值物件Render是隻讀資料,所以不需要將值物件Render更新到場景圖資料中 }; ``` # 總結 ## 本文成果 我們通過本文的領域驅動設計,獲得了下面的成果: 1、使用者邏輯和引擎邏輯 2、分層架構檢視和每一層的設計 3、領域驅動設計的戰略成果 1)引擎子域和限界上下文劃分 2)限界上下文對映圖 4、領域驅動設計的戰術成果 1)領域概念 2)領域檢視 5、資料檢視和PO的相關設計 6、一些細節的設計 7、基本的優化 本文解決了上文的不足之處: > 1、場景邏輯和WebGL API的呼叫邏輯混雜在一起 本文識別出使用者index.html和引擎這兩個角色,分離了使用者邏輯和引擎,從而解決了這個不足 > 2、存在重複程式碼: 1)在_init函式的“初始化所有Shader”中有重複的模式 2)在_render中,渲染三個三角形的程式碼非常相似 3)Utils的sendModelUniformData1和sendModelUniformData2有重複的模式 本文提出了值物件InitShader和值物件Render,分別用一份程式碼實現“初始化每個Shader”和“渲染每個三角形”,然後分別在遍歷對應的值物件時呼叫對應的一份程式碼,從而消除了重複 > 3、_init傳遞給主迴圈的資料過於複雜 本文對資料進行了設計,將資料分為VO、DTO、DO、PO,從而不再傳遞資料,解決了這個不足 ## 本文不足之處 1、倉庫與領域模型之間存在迴圈依賴 2、沒有隔離基礎設施層的“資料”的變化對領域層的影響 如在支援多執行緒時,需要增加渲染執行緒的資料,則不應該影響支援單執行緒的相關程式碼 3、沒有隔離“WebGL”的變化 如在支援WebGL2時,不應該影響支援WebGL1的程式碼 ## 下文概要 在下文中,我們會根據本文的成果,具體實現從最小的3D程式中提煉引擎。 [1]: http://assets.processon.com/chart_image/5e51f95ce4b0362764fd217f.png [2]: http://assets.processon.com/chart_image/5e5ee2b1e4b09d23878efdfc.png [3]: http://assets.processon.com/chart_image/5e5ee330e4b0a967bb339a5e.png [4]: http://assets.processon.com/chart_image/5e521abae4b02bc3ad5c3e97.png [5]: http://assets.processon.com/chart_image/5e5ee445e4b09d23878efe2d.png [6]: http://assets.processon.com/chart_image/5e51e8b4e4b0d4dc8768b6b8.png [7]: http://assets.processon.com/chart_image/5e58f736e4b0541c5e12bacb.png [8]: http://assets.processon.com/chart_image/5e58f53ae4b069f82a187cf8.png [9]: http://assets.processon.com/chart_image/5e58f5aee4b0d4dc8774f134.png [10]: http://assets.processon.com/chart_image/5e58f862e4b02bc3ad683dd5.png [11]: http://assets.processon.com/chart_image/5e58f6a8e4b036276509