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

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

上一篇博文

从0开发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)给出限界上下文映射图
3)给出流程图
5、进行领域驱动设计的战术设计
1)识别领域概念
2)建立领域模型,给出领域视图
6、设计数据,给出数据视图
7、根据用例图,设计分层架构的API层
8、根据API层的设计,设计分层架构的应用服务层
9、进行一些细节的设计:
1)使用Result处理错误
2)使用“Discriminated Union类型”来加强值对象的值类型约束
10、基本的优化

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

  • 持久化数据
    因为我们并没有使用数据库,不需要离线存储,所以本文提到的持久化数据是指:从程序启动到程序结束时,将数据保存到内存中
  • VO、DTO、DO、PO
    这些属于领域驱动设计中数据的相关概念,详见从0开发3D引擎(补充):介绍领域驱动设计->数据
  • “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,开启主循环

引擎逻辑

  • 实现主循环

用伪代码初步设计index.html

根据对最小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之间的转换。

对于“仓库”、“数据”,我们会在后面的“设计数据”中详细分析。

分析“基础设施层”的“外部”

“外部”负责与引擎的外部交互。
它包含两个部分:

  • Js库
    使用FFI封装引擎调用的Js库。
  • 外部对象
    使用FFI定义外部对象,如:
    最小3D程序的DomExtend.re可以放在这里,因为它依赖了“window”这个外部对象;
    Utils.re的error函数也可以放在这里,因为它们依赖了“js异常”这个外部对象。

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

根据通用语言:
此处输入图片的描述

我们已经划分出了“场景图上下文”、“初始化上下文”、“主循环上下文”,这三个限界上下文应该分别位于三个子域中:“场景”、“初始化”、“主循环”。

现在我们在“初始化”子域中划分出更多的上下文:
经过前面识别的用户逻辑和通过用伪代码初步设计index.html,我们知道“初始化上下文”中的“初始化场景”步骤是由用户实现的:用户准备场景数据,调用引擎API设置场景数据。除了这个步骤,另外两个步骤都由引擎实现,因此可以将其建模为限界上下文:“保存WebGL上下文”、“初始化所有Shader”。

现在我们在“主循环”子域中划分出更多的上下文:
除开事件,其它三个步骤可以建模为限界上下文:“设置清空颜色缓冲时的颜色值”、“清空画布”、“渲染”。

现在我们根据通用语言和识别的引擎逻辑,划分更多的限界上下文和子域:
根据引擎逻辑:

  • 获得canvas
  • 虽然不用保存配置项,但是要根据配置项和canvas,保存从canvas获得的webgl的上下文

可以知道限界上下文“保存WebGL上下文”需要获得canvas,因此可以划分限界上下文“画布”,它对应子域“页面”。

根据引擎逻辑,我们知道限界上下文“初始化所有Shader”、“设置清空颜色缓冲时的颜色值”、“清空画布”、“渲染”都需要调用WebGL上下文的方法(即调用WebGL API,如drawElements),因此可以划分限界上下文“上下文”,它对应子域“WebGL上下文”。

根据引擎逻辑:

  • 创建和初始化三个三角形的VBO
  • 传递三角形的顶点数据
    需要绑定三角形的VBO的vertex buffer
  • 渲染三角形时要绘制三角形
    需要绑定三角形的VBO的index buffer

可以知道引擎需要管理VBO,因此可以划分限界上下文“VBO管理”,它对应子域“WebGL对象管理”.

现在我们仔细分析通用语言中的“场景图上下文”,我们可以看到该上下文实际上包含两个聚合根:Scene和Shader。因为一个限界上下文应该只有一个聚合根,因此这提示我们,需要划分限界上下文“着色器”,它对应子域“着色器”,将聚合根Shader移到该限界上下文中。

根据引擎逻辑:

  • 计算view matrix
  • 计算projection matrix
  • 计算三个三角形的model matrix

这些逻辑需要操作矩阵和向量,因此可以划分限界上下文“数学”,它对应子域“数据结构”。

另外,我需要一些通用的值对象来保存一些数据,如使用值对象Color3来保存三角形的颜色数据(r、g、b三个分量)。因此可以划分限界上下文“容器”,位于子域“数据结构”中。

综上所述,我们可以划分出子域和限界上下文,如下图所示:
此处输入图片的描述

给出限界上下文映射图

现在我们来说明下限界上下文之间关系是怎么来的:

  • 子域“数据结构”属于通用子域,提供给其它子域使用。它完全独立,因此它与其它子域的关系属于“遵奉者”
  • 在前面的“划分引擎子域和限界上下文”中,我们已经知道限界上下文“画布”提供数据-“一个画布”给限界上下文“保存WebGL上下文”使用。它完全独立,因此它们的关系属于“遵奉者”
  • 因为限界上下文“初始化所有Shader”和子域“主循环”的各个限界上下文都依赖限界上下文“保存WebGL上下文”产生的数据:WebGL上下文,因此它们的关系属于“客户方——供应方开发”
  • 因为限界上下文“初始化所有Shader”需要从限界上下文“着色器”中获得数据,并作出防腐设计,因此它们的关系属于:“开放主机服务/发布语言”->“防腐层”
  • 同理,因为限界上下文“渲染”需要从限界上下文“场景图”中获得数据,并作出防腐设计,因此它们的关系属于:“开放主机服务/发布语言”->“防腐层”
  • 因为限界上下文“渲染”依赖限界上下文“初始化所有Shader”产生的数据:Program,所以它们的关系属于“客户方——供应方开发”
  • 限界上下文“VBO管理”提供给限界上下文“渲染”使用。它完全独立,因此它们的关系属于“遵奉者”
  • 限界上下文“上下文”提供WebGL上下文的方法(即WebGL API)给子域“初始化”和子域“主循环”的各个限界上下文使用。它完全独立,因此它们的关系属于“遵奉者”

综上所述,限界上下文映射图如下图所示:
此处输入图片的描述

图中标志的解释:

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

DDD(领域驱动设计)中各种限界上下文关系的介绍详见上下文映射图

现在我们来分析下防腐层(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)限界上下文映射图
3)流程图
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程序中提炼引擎。

posted @ 2020-03-04 12:52  杨元超  阅读(715)  评论(0编辑  收藏  举报