从0开发3D引擎(十):使用领域驱动设计,从最小3D程序中提炼引擎(第一部分)

你们好,本文使用领域驱动设计的方法,从新设计最小3D程序,识别出“用户”和“引擎”角色,给出各类设计的视图。html

上一篇博文

从0开发3D引擎(九):实现最小的3D程序-“绘制三角形”java

下一篇博文

从0开发3D引擎(十一):使用领域驱动设计,从最小3D程序中提炼引擎(第二部分)git

前置知识

从0开发3D引擎(补充):介绍领域驱动设计github

回顾上文

上文得到了下面的成果:
一、最小3D程序
二、领域驱动设计的通用语言web

最小3D程序完整代码地址

Book-Demo-Triangle Github Repo数据库

通用语言

此处输入图片的描述

将会在本文解决的不足之处

一、场景逻辑和WebGL API的调用逻辑混杂在一块儿
二、存在重复代码:
1)在_init函数的“初始化全部Shader”中有重复的模式
2)在_render中,渲染三个三角形的代码很是类似
3)Utils的sendModelUniformData1和sendModelUniformData2有重复的模式
三、_init传递给主循环的数据过于复杂编程

本文流程

咱们根据上文的成果,进行下面的设计:
一、识别最小3D程序的用户逻辑和引擎逻辑
二、根据用户逻辑,给出用例图,用于设计API
三、设计分层架构,给出架构视图
四、进行领域驱动设计的战略设计
1)划分引擎子域和限界上下文
2)给出限界上下文映射图
五、进行领域驱动设计的战术设计
1)识别领域概念
2)创建领域模型,给出领域视图
六、设计数据,给出数据视图
七、根据用例图,设计分层架构的API层
八、根据API层的设计,设计分层架构的应用服务层
九、进行一些细节的设计:
1)使用Result处理错误
2)使用“Discriminated Union类型”来增强值对象的值类型约束
十、基本的优化canvas

解释本文使用的领域驱动设计的一些概念

  • 持久化数据
    由于咱们并无使用数据库,不须要离线存储,因此本文提到的持久化数据是指:从程序启动到程序结束时,将数据保存到内存中api

  • “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程序的顶层包含的用户逻辑和引擎逻辑

从顶层来看,包含三个部分的逻辑:建立场景、初始化、主循环

咱们依次识别它们的用户逻辑和引擎逻辑:
一、建立场景
用户逻辑

  • 准备场景数据
    场景数据包括canvas的id、三个三角形的数据等
  • 调用API,保存某个场景数据
  • 调用API,得到某个场景数据

引擎逻辑

  • 保存某个场景数据
  • 得到某个场景数据

二、初始化

用户逻辑

  • 调用API,进行初始化

引擎逻辑

  • 实现初始化

三、主循环

用户逻辑

  • 调用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函数负责初始化

如今依次分析初始化的每一个步骤对应的代码:
一、得到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的上下文

二、初始化全部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”中有重复的模式

解决方案:
一、得到全部Shader的Shader名称和GLSL组集合
二、遍历这个集合:
1)建立Program
2)初始化Shader

这样的话,就只须要写一份“初始化每一个Shader”的代码了,消除了重复。

根据以上的分析,咱们识别出下面的引擎逻辑:

  • 得到全部Shader的Shader名称和GLSL组集合
  • 遍历这个集合
    • 建立Program
    • 初始化Shader

三、初始化场景
相关代码为:

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函数对应主循环,如今依次分析主循环的每一个步骤对应的代码:

一、开启主循环
相关代码为:

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

用户逻辑

引擎逻辑

  • 调用requestAnimationFrame开启主循环

如今进入_loopBody函数:
二、设置清空颜色缓冲时的颜色值
相关代码为:

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

  data;
};

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

用户逻辑

  • 准备清空颜色缓冲时的颜色值
  • 调用API,传入清空颜色缓冲时的颜色值

引擎逻辑

  • 保存清空颜色缓冲时的颜色值
  • 设置清空颜色缓冲时的颜色值

三、清空画布
相关代码为:

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

  data;
};

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

用户逻辑

引擎逻辑

  • 清空画布

四、渲染

相关代码为:

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异常”这个外部对象。

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

以下图所示:
此处输入图片的描述

给出限界上下文映射图

以下图所示:
此处输入图片的描述

其中:

  • “U”为上游,“D”为下游
    下游依赖上游
  • “C”为遵奉者
  • “CSD”为客户方——供应方开发
  • “OHS”为开放主机服务
  • “PL”为发布语言
  • “ACL”为防腐层

上下文关系的介绍详见上下文映射图

如今咱们来分析下防腐层(ACL)的设计,其中相关的领域模型会在后面的“领域视图”中给出。

“初始化全部Shader”限界上下文的防腐设计

一、“着色器”限界上下文提供着色器的DO数据
二、“初始化全部Shader”限界上下文的领域服务BuildInitShaderData做为防腐层,将着色器DO数据转换为值对象InitShader
三、“初始化全部Shader”限界上下文的领域服务InitShader遍历值对象InitShader,初始化每一个Shader

经过这样的设计,隔离了领域服务InitShader和“着色器”限界上下文。

设计值对象InitShader

根据识别的引擎逻辑,能够得知值对象InitShader的值是全部Shader的Shader名称和GLSL组集合,所以咱们能够给出值对象InitShader的类型定义:

type singleInitShader = {
  shaderId: string,
  vs: string,
  fs: string,
};

//值对象InitShader类型定义
type initShader = list(singleInitShader);

“渲染”限界上下文的防腐设计

一、“场景图”限界上下文提供场景图的DO数据
二、“渲染”限界上下文的领域服务BuildRenderData做为防腐层,将场景图DO数据转换为值对象Render
三、“渲染”限界上下文的领域服务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)在_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有重复的模式”

那是否能够把全部三角形的颜色数据统一用一个数据结构来保存,而后在渲染三角形->传递三角形的颜色数据时,遍历该数据结构,只用一个函数(而不是两个函数:sendModelUniformData一、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类型”须要操做容器,性能较差,因此在性能热点处:
    一、在性能热点开始前,经过一次遍历操做,将全部相关的值对象的值从“Discriminated Union类型”中取出来。其中取出的值是primitive类型,即int、string等没有用容器包裹的原始类型
    二、在性能热点处操做primtive类型的值
    三、在性能热点结束后,经过一次遍历操做,将更新后的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)限界上下文映射图
四、领域驱动设计的战术成果
1)领域概念
2)领域视图
五、数据视图和PO的相关设计
六、一些细节的设计
七、基本的优化

本文解决了上文的不足之处:

一、场景逻辑和WebGL API的调用逻辑混杂在一块儿

本文识别出用户index.html和引擎这两个角色,分离了用户逻辑和引擎,从而解决了这个不足

二、存在重复代码:
1)在_init函数的“初始化全部Shader”中有重复的模式
2)在_render中,渲染三个三角形的代码很是类似
3)Utils的sendModelUniformData1和sendModelUniformData2有重复的模式

本文提出了值对象InitShader和值对象Render,分别用一份代码实现“初始化每一个Shader”和“渲染每一个三角形”,而后分别在遍历对应的值对象时调用对应的一份代码,从而消除了重复

三、_init传递给主循环的数据过于复杂

本文对数据进行了设计,将数据分为VO、DTO、DO、PO,从而再也不传递数据,解决了这个不足

本文不足之处

一、仓库与领域模型之间存在循环依赖
二、没有隔离基础设施层的“数据”的变化对领域层的影响
如在支持多线程时,须要增长渲染线程的数据,则不该该影响支持单线程的相关代码
三、没有隔离“WebGL”的变化
如在支持WebGL2时,不该该影响支持WebGL1的代码

下文概要

在下文中,咱们会根据本文的成果,具体实现从最小的3D程序中提炼引擎。

相关文章
相关标签/搜索