­

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

大家好,本文使用領域驅動設計的方法,重新設計最小3D程式,識別出「用戶」和「引擎」角色,給出各種設計的視圖。

上一篇博文

從0開發3D引擎(九):實現最小的3D程式-「繪製三角形」

下一篇博文

前置知識

從0開發3D引擎(補充):介紹領域驅動設計

回顧上文

上文獲得了下面的成果:
1、最小3D程式
2、領域驅動設計的通用語言

最小3D程式完整程式碼地址

Book-Demo-Triangle Github Repo

通用語言

此處輸入圖片的描述

將會在本文解決的不足之處

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數據。
    如:
//定義聚合根Scene的PO的類型  type scene = {      ...  };    //定義PO的類型  type po = {      scene  };    「PO」的類型為po,「Scene PO」的類型為scene
  • 「XXX DO」(XXX為聚合根名,如Scene)
    「XXX DO」是指XXX(聚合根)的DO數據。
    如:
module SceneEntity = {      //定義聚合根Scene的DO的類型      type t = {          ...      };  };    「Scene DO」的類型為SceneEntity.t

本文的領域驅動設計選型

  • 使用分層架構
  • 領域模型(領域服務、實體、值對象)使用貧血模型

這只是目前的選型,在後面的文章中我們會修改它們。

設計

引擎名

TinyWonder

因為本系列開發的引擎的素材來自於Wonder.js,只有最小化的功能,所以叫TinyWonder

識別最小3D程式的頂層包含的用戶邏輯和引擎邏輯

從頂層來看,包含三個部分的邏輯:創建場景、初始化、主循環

我們依次識別它們的用戶邏輯和引擎邏輯:
1、創建場景
用戶邏輯

  • 準備場景數據
    場景數據包括canvas的id、三個三角形的數據等
  • 調用API,保存某個場景數據
  • 調用API,獲得某個場景數據

引擎邏輯

  • 保存某個場景數據
  • 獲得某個場景數據

2、初始化

用戶邏輯

  • 調用API,進行初始化

引擎邏輯

  • 實現初始化

3、主循環

用戶邏輯

  • 調用API,開啟主循環

引擎邏輯

  • 實現主循環

根據對最小3D程式的頂層的分析,用偽程式碼初步設計index.html

index.html

/*  「User.」表示這是用戶要實現的函數  「EngineJsAPI.」表示這是引擎提供的API函數    使用"xxx()"代表某個函數  */    //由用戶實現  module User = {      let prepareSceneData = () => {          let (canvasId, ...) = ...            ...            (canvasId, ...)      };        ...  };    let (canvasId, ...) = User.prepareSceneData();    //保存某個場景數據到引擎中  EngineJsAPI.setXXXSceneData(canvasId, ...);    EngineJsAPI.進行初始化();  EngineJsAPI.開啟主循環();

識別最小3D程式的初始化包含的用戶邏輯和引擎邏輯

初始化對應的通用語言為:
此處輸入圖片的描述

最小3D程式的_init函數負責初始化

現在依次分析初始化的每個步驟對應的程式碼:
1、獲得WebGL上下文
相關程式碼為:

  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
相關程式碼為:

  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組與三角形關聯?

我們看下相關的通用語言:
此處輸入圖片的描述

三角形與Shader一一對應,而Shader又與GLSL組一一對應。

因此,我們可以在三角形中增加數據:Shader名稱(類型為string),從而使三角形通過Shader名稱與GLSL組一一關聯。

更新後的三角形通用語言為:
此處輸入圖片的描述

根據以上的分析,我們識別出下面的用戶邏輯:

  • 準備兩個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、初始化場景
相關程式碼為:

  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程式的主循環包含的用戶邏輯和引擎邏輯

主循環對應的通用語言為:
此處輸入圖片的描述

對應最小3D程式的_loop函數對應主循環,現在依次分析主循環的每個步驟對應的程式碼:

1、開啟主循環
相關程式碼為:

let rec _loop = data =>    DomExtend.requestAnimationFrame((time: float) => {      _loopBody(data);      _loop(data) |> ignore;    });

用戶邏輯

引擎邏輯

  • 調用requestAnimationFrame開啟主循環

現在進入_loopBody函數:
2、設置清空顏色緩衝時的顏色值
相關程式碼為:

let _clearColor = ((gl, sceneData) as data) => {    WebGL1.clearColor(0., 0., 0., 1., gl);      data;  };    let _loopBody = data => {    data |> ... |> _clearColor |> ...  };

用戶邏輯

  • 準備清空顏色緩衝時的顏色值
  • 調用API,傳入清空顏色緩衝時的顏色值

引擎邏輯

  • 保存清空顏色緩衝時的顏色值
  • 設置清空顏色緩衝時的顏色值

3、清空畫布
相關程式碼為:

let _clearCanvas = ((gl, sceneData) as data) => {    WebGL1.clear(      WebGL1.getColorBufferBit(gl) lor WebGL1.getDepthBufferBit(gl),      gl,    );      data;  };    let _loopBody = data => {    data |> ... |> _clearCanvas |> ...  };

用戶邏輯

引擎邏輯

  • 清空畫布

4、渲染

相關程式碼為:

let _loopBody = data => {    data |> ... |> _render;  };

用戶邏輯

引擎邏輯

  • 渲染

現在進入_render函數,我們來分析「渲染」的每個步驟對應的程式碼:
1)設置WebGL狀態

_render函數中的相關程式碼為:

  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函數中的相關程式碼為:

  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函數中的相關程式碼為:

  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函數中的相關程式碼為:

  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函數中的相關程式碼為:

  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實現的邏輯作為用例,移到角色「引擎」中。
得到的用例圖如下所示:
此處輸入圖片的描述

設計架構,給出架構視圖

我們使用四層的分層架構,架構視圖如下所示:
此處輸入圖片的描述

不允許跨層訪問。

對於「API層」和「應用服務層」,我們會在給出領域視圖後,詳細設計它們。

我們加入了「倉庫」,使「實體」只能通過「倉庫」來操作「數據」,隔離「數據」和「實體」。
只有「實體」負責持久化數據,所以只有「實體」依賴「倉庫」,「值對象」和「領域服務」都不應該依賴「倉庫」。

之所以「倉庫」依賴了「領域服務」、「實體」、「值對象」,是因為「倉庫」需要調用它們的函數,實現「數據」的PO和領域層的DO之間的轉換。

對於「倉庫」、「數據」、PO、DO,我們會在後面的「設計數據」中詳細分析。

分析「基礎設施層」的「外部」

「外部」負責與引擎的外部交互。
它包含兩個部分:

  • Js庫
    使用FFI封裝引擎調用的Js庫。
  • 外部對象
    使用FFI定義外部對象,如:
    最小3D程式的DomExtend.re可以放在這裡,因為它依賴了「window」這個外部對象;
    Utils.re的error函數也可以放在這裡,因為它們依賴了「js異常」這個外部對象。

劃分引擎子域和限界上下文

如下圖所示:
此處輸入圖片的描述

給出限界上下文映射圖

如下圖所示:
此處輸入圖片的描述

其中:

  • 「C」為遵奉者
  • 「CSD」為客戶方——供應方開發
  • 「OHS」為開放主機服務
  • 「PL」為發布語言
  • 「ACL」為防腐層

上下文關係的介紹詳見上下文映射圖

現在我們來分析下防腐層(ACL)的設計,其中相關的領域模型會在後面的「領域視圖」中給出。

「初始化所有Shader」限界上下文的防腐設計

1、「著色器」限界上下文提供著色器的DO數據
2、「初始化所有Shader」限界上下文的領域服務BuildInitShaderData作為防腐層,將著色器DO數據轉換為值對象InitShader
3、「初始化所有Shader」限界上下文的領域服務InitShader遍歷值對象InitShader,初始化每個Shader

通過這樣的設計,隔離了領域服務InitShader和「著色器」限界上下文。

設計值對象InitShader

根據識別的引擎邏輯,可以得知值對象InitShader的值是所有Shader的Shader名稱和GLSL組集合,因此我們可以給出值對象InitShader的類型定義:

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函數的參數如下:

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中

對於渲染數據中的「場景中唯一的相機數據」:

          (              (                (eyeX, eyeY, eyeZ),                (centerX, centerY, centerZ),                (upX, upY, upZ),              ),              (near, far, fovy, aspect),            ),

根據識別的引擎邏輯,我們知道在渲染場景中所有的三角形前,需要根據這些渲染數據計算一個view matrix和一個projection matrix。因為值對象Render是為渲染所有三角形服務的,所以值對象Render的相機數據應該為一個view matrix和一個projection matrix

對於下面的渲染數據:

          (position1, position2, position3),

根據識別的引擎邏輯,我們知道在渲染場景中所有的三角形前,需要根據這些渲染數據計算每個三角形的model matrix,所以值對象Render的三角形數據應該包含每個三角形的model matrix

對於下面的渲染數據:

          (indices1, indices2, indices3),

根據識別的引擎邏輯,我們知道在調用drawElements繪製每個三角形時,需要根據這些渲染數據計算頂點個數,作為drawElements的第二個形參,所以值對象Render的三角形數據應該包含每個三角形的頂點個數

對於下面的渲染數據:

          (program1, program2),            (vertexBuffer1, indexBuffer1),            (vertexBuffer2, indexBuffer2),            (vertexBuffer3, indexBuffer3),

它們可以作為值對象Render的三角形數據。經過抽象後,值對象Render的三角形數據應該包含每個三角形關聯的program、每個三角形的VBO數據(一個vertex buffer和一個index buffer)

對於下面的渲染數據(三個三角形的顏色數據),我們需要從中設計出值對象Render的三角形數據包含的顏色數據:

          (color1, (color2_1, color2_2), color3),

我們需要將其統一為一個數據結構,才能作為值對象Render的顏色數據。

我們回顧下將會在本文解決的不足之處:

2、存在重複程式碼:

2)在_render中,渲染三個三角形的程式碼非常相似
3)Utils的sendModelUniformData1和sendModelUniformData2有重複的模式

這兩處的重複跟顏色的數據結構不統一是有關係的。
我們來看下最小3D程式中相關的程式碼:
Main.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

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));
在傳遞該三角形的顏色數據時,遍歷列表,傳遞每個顏色數據。

相關偽程式碼如下:

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時執行它。

相關偽程式碼如下:

let 渲染每個三角形 = (每個三角形的數據) => {...};    let _render =      (...) => {      ...      構造值對象Render(場景圖數據)      |>      遍歷值對象Render的三角形數據((每個三角形的數據) => {              渲染每個三角形(每個三角形的數據)          });      ...  };
給出值對象Render的類型定義

通過前面對渲染數據的分析,可以給出值對象Render的類型定義:

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名稱 + 三角形的顏色

建立領域模型,給出領域視圖

領域視圖如下所示,圖中包含了領域模型之間的所有聚合、組合關係,以及領域模型之間的主要依賴關係
此處輸入圖片的描述

設計數據

分層數據視圖

如下圖所示:
此處輸入圖片的描述

設計PO Container

PO Container作為一個容器,負責保存PO到記憶體中。

PO Container應該為一個全局Record,有一個可變欄位po,用於保存PO

相關的設計為:

type poContainer = {    mutable po  };    let poContainer = {    po: 創建PO()  };

這裡有兩個壞味道:

  • poContainer為全局變數
    這是為了讓poContainer在程式啟動到終止期間,一直存在於記憶體中
  • 使用了可變欄位po
    這是為了在設置PO到poContainer中時,讓poContainer在記憶體中始終只有一份

我們應該盡量使用局部變數和不可變數據/不可變操作,消除共享的狀態。但有時候壞味道不可避免,因此我們使用下面的策略來處理壞味道:

  • 把壞味道集中和隔離到一個可控的範圍
  • 使用容器來封裝副作用
    如函數內部發生錯誤時,可以用容器來包裝錯誤資訊,返回給函數外部,在外部的某處(可控的範圍)集中處理錯誤。詳見後面的「使用Result處理錯誤」

設計PO

我們設計如下:

  • 用Record作為PO的數據結構
  • PO的欄位對應聚合根的數據
  • PO是不可變數據

相關的設計為:

type po = {      //各個聚合根的數據        canvas,      shaderManager,      scene,      context,      vboManager  };

因為現在資訊不夠,所以不設計聚合根的具體數據,留到實現時再設計它們。

設計容器管理

容器管理負責讀/寫PO Container的PO,相關設計如下:

type getPO = unit => po;  type setPO = po => unit;

設計倉庫

職責

  • 將來自領域層的DO轉換為PO,設置到PO Container中
  • 從PO Container中獲得PO,轉換為DO傳遞給領域層

偽程式碼和類型簽名

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類型抽象類型等類型是Reason獨有的,不能作為API的輸入參數和輸出結果。
      )

劃分API模組,設計具體的API

首先根據用例圖的用例,劃分API模組;
然後根據API的設計原則,在對應模組中設計具體的API,給出API的類型簽名。

API模組及其API的設計為:

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基本相同。

應用服務模組及其函數設計為:

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引擎(五):函數式編程及其在引擎中的應用中介紹了「使用Result來處理錯誤」,它相比「拋出異常」的錯誤處理方式,有很多優點。

我們在引擎中主要使用Result來處理錯誤。但是在後面的「優化」中,我們可以看到為了優化,引擎也使用了「拋出異常」的錯誤處理方式。

使用「Discriminated Union類型」來加強值對象的值類型約束

我們以值對象Matrix為例,來看下如何加強值對象的值類型約束,從而在編譯檢查時確保類型正確:
Matrix的值類型為Js.Typed_array.Float32Array.t,這樣的類型設計有個缺點:不能與其它Js.Typed_array.Float32Array.t類型的變數區分開。

因此,在Matrix中可以使用Discriminated Union類型來定義「Matrix」類型:

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」
    優化的偽程式碼為:
let 初始化所有Shader = (...) => {      ...      //著色器數據中有「Discriminated Union」類型的數據,而構造後的值對象InitShader的值均為primitive類型      構造為值對象InitShader(著色器數據)      |>      //使用Result.tryCatch將異常轉換為Result      Result.tryCatch((值對象InitShader) => {          //使用「拋出異常」的方式處理錯誤          根據值對象InitShader,初始化每個Shader      });      //因為值對象InitShader是只讀數據,所以不需要將值對象InitShader更新到著色器數據中  };
  • 渲染時,優化「遍歷和渲染每個三角形」
    優化的偽程式碼為:
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程式中提煉引擎。