1. 程式人生 > >從0開發3D引擎(九):實現最小的3D程式-“繪製三角形”

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

目錄

  • 上一篇博文
  • 執行測試截圖
  • 需求分析
    • 目標
    • 特性
    • 頭腦風暴
    • 確定需求
  • 總體設計
  • 具體實現
    • 新建Engine3D專案
    • 實現上下文
    • 實現_init
      • 實現“獲得WebGL上下文”
      • 實現“初始化所有Shader”
      • 實現“初始化場景”
      • 實現“設定清空顏色緩衝時的顏色值”
      • 返回用於主迴圈的資料
    • 實現_loop
      • 實現“主迴圈”
      • 實現“clearCanvas”
      • 實現“_render”
    • 最終的分層和領域模型
  • 總結
  • 本文完整程式碼地址

大家好,本文開始程式設計,實現最小的3D程式。

我們首先進行需求分析,確定功能點;
然後進行總體設計,劃分模組,並且對模組進行頂層設計,給出型別簽名和實現的虛擬碼;
最後進行具體實現,實現各個模組。

注:在Reason中,一個Reason檔案(如Main.re)就是一個模組(Module)。

上一篇博文

從0開發3D引擎(八):準備“搭建引擎雛形”

執行測試截圖

測試場景包括三個三角形:

需求分析

首先,我們分析最小3D程式的目標和特性;
接著,根據特性,我們進行頭腦風暴,識別出功能關鍵點和擴充套件點;
最後,根據功能關鍵點和擴充套件點,我們確定最小3D程式的功能點。

目標

可從最小3D程式中提煉出通用的、最簡化的引擎雛形

特性

為了達成目標,最小3D程式應該具備以下的特性:

  • 簡單
    最小3D程式應該很簡單,便於我們分析和提煉。
  • 具有3D程式的通用特性
    為了使從中提煉出的引擎雛形可擴充套件,最小3D程式需要包含3D程式主要的流程和通用的模式

頭腦風暴

現在,我們根據特性,進行頭腦風暴,識別出最小3D程式的功能關鍵點和擴充套件點。

下面從兩個方面來分析:
1、從功能上分析
最簡單的功能就是沒有任何互動,只是繪製模型;
而最簡單的模型就是三角形;

識別功能關鍵點:
a)繪製三角形
b)只渲染,沒有任何互動

2、從流程上分析
3D程式應該包含兩個步驟:
1)初始化
進一步分解,識別出最明顯的子步驟:

//“|>”是函數語言程式設計中的管道操作。例如:“A |> B”表示先執行A,然後將其返回值傳給B,再執行B
初始化 = 初始化Shader |> 初始化場景

識別功能擴充套件點:
a)多組GLSL
因為在3D場景中,通常有各種渲染效果,如光照、霧、陰影等,每種渲染效果對應一個或多個Shader,而每個Shader對應一組GLSL,每組GLSL包含頂點GLSL和片段GLSL,所以最小3D程式需要支援多組GLSL。

2)主迴圈
進一步分解,識別出最明顯的子步驟:

主迴圈 =  使用requestAnimationFrame迴圈執行每一幀

每一幀 = 清空畫布 |> 渲染

渲染 = 設定WebGL狀態 |> 設定相機 |> 繪製場景中所有的模型

識別功能擴充套件點:
b)多個渲染模式
3D場景往往需要用不同的模式來渲染不同的模型,如用不同的模式來渲染所有透明的模型和渲染所有非透明的模型。

c)多個WebGL狀態
每個渲染模式需要設定對應的多個WebGL狀態。

d)多個相機
3D場景中通常有多個相機。在渲染時,設定其中一個相機作為當前相機。

e)多個模型
3D場景往往包含多個模型。

f)每個模型有不同的Transform
Transform包括位置、旋轉和縮放

確定需求

現在,我們根據功能關鍵點和擴充套件點,確定最小3D程式的需求。

下面分析非功能性需求和功能性需求:

非功能性需求
最小3D程式不考慮非功能性需求

功能性需求
我們已經識別了以下的功能關鍵點:
a)繪製三角形
b)只渲染,沒有任何互動

結合功能關鍵點,我們對功能擴充套件點進行一一分析和決定,得到最小3D程式要實現的功能點:
a)多組GLSL
為了簡單,實現兩組GLSL,它們只有細微的差別,從而可以用相似的程式碼來渲染使用不同GLSL的三角形,減少程式碼複雜度
b)多個渲染模式
為了簡單,只有一個渲染模式:渲染所有非透明的模型
c)多個WebGL狀態
我們設定常用的兩個狀態:開啟深度測試、開啟背面剔除。
d)多個相機
為了簡單,只有一個相機
e)多個模型
繪製三個三角形
f)每個模型有不同的Transform
為了簡單,每個三角形有不同的位置(它們的z值,即深度不一樣,從而測試“開啟深度測試”的效果),不考慮旋轉和縮放

根據上面的分析,我們給出最小3D程式要實現的功能點:

  • 只渲染,沒有互動
  • 有兩組GLSL
  • 場景有三個三角形
    第一個三角形用第一組的GLSL;
    第二個三角形用第二組的GLSL;
    第三個三角形用第一組的GLSL;
  • 所有三角形都是非透明的
  • 開啟深度測試和背面剔除
  • 只有一個固定的透視投影相機
  • 三角形的位置不同,不設定旋轉和縮放

總體設計

現在,我們對最小3D程式進行總體設計:

1、我們來看下最小3D程式的上下文:

程式的邏輯放在Main模組的main函式中;
index.html頁面執行main函式;
在瀏覽器中執行index.html頁面,繪製三角形場景。

2、我們用型別簽名和虛擬碼,對main函式進行頂層設計:

//unit表示無返回型別,類似於C語言的void
type main = unit => unit;
let main = () => {
    _init() 
    //開啟主迴圈
    |> _loop
    //使用“ignore”來忽略_loop的返回值,從而使main函式的返回型別為unit
    |> ignore;
};

//data是用於主迴圈的資料
type _init = unit => data;
let _init = () => {
    獲得WebGL上下文 
    //因為有兩組GLSL,所以有兩個Shader
    |> 初始化所有Shader 
    |> 初始化場景
};

type _loop = data => int;
//用“rec”關鍵字將_loop設為遞迴呼叫
let rec _loop = (data) => 
    requestAnimationFrame((time:int) => {
        //執行主迴圈的邏輯
        _loopBody(data);
        //遞迴呼叫_loop
        _loop(data) |> ignore;    
    });
    
type _loopBody = data => unit;
let _loopBody = (data) => {
    data
    |> _clearCanvas
    |> _render
};

type _render = data => unit;
let _render = (data) => {
    設定WebGL狀態 
    |> 繪製三個三角形
};

具體實現

現在,我們具體實現最小3D程式,使其能夠在瀏覽器中執行。

新建Engine3D專案

首先通過從0開發3D引擎(三):搭建開發環境,搭建Reason的開發環境;
然後新建空白的Engine3D資料夾,將Reason-Example專案的內容拷貝到該專案中,刪除src/First.re檔案;
在專案根目錄下,依次執行“yarn install”,“yarn watch”,“yarn start”。

Engine3D專案結構為:

src/資料夾放置Reason程式碼;
lib/es6_global/資料夾放置編譯後的js程式碼(使用es6 module模組規範)。

實現上下文

在src/中加入Main.re檔案,定義一個空的main函式:

let main = () => {
    console.log("main");
};

重寫index.html頁面為:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <title>Demo</title>
</head>

<body>
  <canvas id="webgl" width="400" height="400">
    Please use a browser that supports "canvas"
  </canvas>

  <script type="module">
    import { main } from "./lib/es6_global/src/Main.js";

    window.onload = () => {
      main();
    };
  </script>

</body>

</html>

index.html建立了一個canvas,並通過ES6 module引入了編譯後的Main.js檔案,執行main函式。

執行index.html頁面
瀏覽器地址中輸入 http://127.0.0.1:8080, 執行index.html頁面。
開啟瀏覽器控制檯->Console,可以看到輸出“main”。

實現_init

現在我們來實現main函式,它包括_init和_loop函式。

我們首先實現_init函式,它的總體設計為:

type _init = unit => data;
let _init = () => {
    獲得WebGL上下文 
    |> 初始化所有Shader 
    |> 初始化場景
};

實現“獲得WebGL上下文”

通過以下步驟來實現:
1、獲得canvas dom
需要呼叫window.querySelector方法來獲得它 ,因此需要寫FFI。
在src/中加入DomExtend.re,該檔案放置與Dom互動的FFI。
在其中定義FFI:

type htmlElement = {
  .
  "width": int,
  "height": int,
};

type body;

type document = {. "body": body};

[@bs.send] external querySelector: (document, string) => htmlElement = "";

在Main.re的_init函式中,通過canvas dom id來獲得canvas:

let canvas = DomExtend.querySelector(DomExtend.document, "#webgl");

2、從canvas中獲得webgl1的上下文
需要呼叫canvas的getContext方法,因此需要寫FFI。
在src/中增加Gl.re,該檔案放置與webgl1 API相關的FFI。

在其中定義相關FFI:

type webgl1Context;

type contextConfigJsObj = {
  .
  "alpha": bool,
  "depth": bool,
  "stencil": bool,
  "antialias": bool,
  "premultipliedAlpha": bool,
  "preserveDrawingBuffer": bool,
};

[@bs.send]
external getWebgl1Context:
  ('canvas, [@bs.as "webgl"] _, contextConfigJsObj) => webgl1Context =
  "getContext";

在Main.re的_init函式中,獲得上下文,指定它的配置項:

let gl =
Gl.getWebgl1Context(
  canvas,
  {
    "alpha": true,
    "depth": true,
    "stencil": false,
    "antialias": true,
    "premultipliedAlpha": true,
    "preserveDrawingBuffer": false,
  }: Gl.contextConfigJsObj,
);

我們通過網上的資料,解釋下配置項:

WebGL上下文屬性:
alpha :布林值,指示畫布是否包含alpha緩衝區.
depth :布林值,指示繪圖緩衝區的深度緩衝區至少為16位.
stencil :布林值,指示繪圖緩衝區具有至少8位的模板緩衝區.
antialias :布林值,指示是否執行抗鋸齒.
premultipliedAlpha :布林值,指示頁面合成器將假定繪圖緩衝區包含具有預乘alpha的顏色.
preserveDrawingBuffer :如果該值為true,則不會清除緩衝區,並且將保留其值,直到作者清除或覆蓋.
failIfMajorPerformanceCaveat :布林值,指示如果系統性能低下是否將建立上下文.

premultipliedAlpha需要設定為true,否則紋理無法進行 Texture Filtering(除非使用最近鄰插值)。具體可以參考Premultiplied Alpha 到底是幹嘛用的
這裡忽略了“failIfMajorPerformanceCaveat“。

實現“初始化所有Shader”

一共有兩個Shader,分別對應一組GLSL。

  • 在src/中加入GLSL.re,定義兩組GLSL

GLSL.re:

let vs1 = {|
  precision mediump float;
  attribute vec3 a_position;
  uniform mat4 u_pMatrix;
  uniform mat4 u_vMatrix;
  uniform mat4 u_mMatrix;

  void main() {
    gl_Position = u_pMatrix * u_vMatrix * u_mMatrix * vec4(a_position, 1.0);
  }
    |};

let fs1 = {|
    precision mediump float;

    uniform vec3 u_color0;

    void main(){
        gl_FragColor = vec4(u_color0,1.0);
    }
    |};

let vs2 = {|
  precision mediump float;
  attribute vec3 a_position;
  uniform mat4 u_pMatrix;
  uniform mat4 u_vMatrix;
  uniform mat4 u_mMatrix;

  void main() {
    gl_Position = u_pMatrix * u_vMatrix * u_mMatrix * vec4(a_position, 1.0);
  }
    |};

let fs2 = {|
    precision mediump float;

    uniform vec3 u_color0;
    uniform vec3 u_color1;

    void main(){
        gl_FragColor = vec4(u_color0 * u_color1,1.0);
    }
    |};

這兩組GLSL類似,它們的頂點GLSL一樣,都傳入了model、view、projection矩陣和三角形的頂點座標a_position;
它們的片段GLSL有細微的差別:第一個的片段GLSL只傳入了一個顏色u_color0,第二個的片段GLSL傳入了兩個顏色u_color0、u_color1。

  • 在Gl.re中定義FFI

Gl.re:

type program;

type shader;

[@bs.send.pipe: webgl1Context] external createProgram: program = "";

[@bs.send.pipe: webgl1Context] external linkProgram: program => unit = "";

[@bs.send.pipe: webgl1Context]
external shaderSource: (shader, string) => unit = "";

[@bs.send.pipe: webgl1Context] external compileShader: shader => unit = "";

[@bs.send.pipe: webgl1Context] external createShader: int => shader = "";

[@bs.get] external getVertexShader: webgl1Context => int = "VERTEX_SHADER";

[@bs.get] external getFragmentShader: webgl1Context => int = "FRAGMENT_SHADER";

[@bs.get] external getCompileStatus: webgl1Context => int = "COMPILE_STATUS";

[@bs.get] external getLinkStatus: webgl1Context => int = "LINK_STATUS";

[@bs.send.pipe: webgl1Context]
external getProgramParameter: (program, int) => bool = "";

[@bs.send.pipe: webgl1Context]
external getShaderInfoLog: shader => string = "";

[@bs.send.pipe: webgl1Context]
external getProgramInfoLog: program => string = "";

[@bs.send.pipe: webgl1Context]
external attachShader: (program, shader) => unit = "";

[@bs.send.pipe: webgl1Context]
external bindAttribLocation: (program, int, string) => unit = "";

[@bs.send.pipe: webgl1Context] external deleteShader: shader => unit = "";
  • 傳入對應的GLSL,初始化兩個shader,建立並獲得兩個program

因為"初始化Shader"是通用邏輯,因此在Main.re的_init函式中提出該函式。

Main.re的_init函式的相關程式碼如下:

//通過丟擲異常來處理錯誤
let error = msg => Js.Exn.raiseError(msg) |> ignore;

let _compileShader = (gl, glslSource: string, shader) => {
  Gl.shaderSource(shader, glslSource, gl);
  Gl.compileShader(shader, gl);

  Gl.getShaderParameter(shader, Gl.getCompileStatus(gl), gl) === false
    ? {
      let message = Gl.getShaderInfoLog(shader, gl);

      error(
        {j|shader info log: $message
        glsl source: $glslSource
        |j},
      );
    }
    : ();

  shader;
};

let _linkProgram = (program, gl) => {
  Gl.linkProgram(program, gl);

  Gl.getProgramParameter(program, Gl.getLinkStatus(gl), gl) === false
    ? {
      let message = Gl.getProgramInfoLog(program, gl);

      error({j|link program error: $message|j});
    }
    : ();
};

let initShader = (vsSource: string, fsSource: string, gl, program) => {
  let vs =
    _compileShader(
      gl,
      vsSource,
      Gl.createShader(Gl.getVertexShader(gl), gl),
    );
  let fs =
    _compileShader(
      gl,
      fsSource,
      Gl.createShader(Gl.getFragmentShader(gl), gl),
    );

  Gl.attachShader(program, vs, gl);
  Gl.attachShader(program, fs, gl);

  //需要確保attribute 0 enabled,具體原因可參考:    http://stackoverflow.com/questions/20305231/webgl-warning-attribute-0-is-disabled-this-has-significant-performance-penalt?answertab=votes#tab-top
  Gl.bindAttribLocation(program, 0, "a_position", gl);

  _linkProgram(program, gl);

  Gl.deleteShader(vs, gl);
  Gl.deleteShader(fs, gl);

  program;
};

let program1 =
gl |> Gl.createProgram |> initShader(GLSL.vs1, GLSL.fs1, gl);

let program2 =
gl |> Gl.createProgram |> initShader(GLSL.vs2, GLSL.fs2, gl);

因為error和initShader函式屬於輔助邏輯,所以我們進行重構,在src/中加入Utils.re,將其移到其中。

實現“初始化場景”

我們在後面實現“渲染”時,要使用drawElements來繪製三角形,因此在這裡不僅需要建立三角形的vertices資料,還需要建立三角形的indices資料。

另外,我們決定使用VBO來儲存三角形的頂點資料。

值得說明的是,我們使用“Geometry”這個概念來指代模型的Mesh結構,Geometry資料就是指三角形的頂點資料,包括vertices、indices等資料。

我們來細化“初始化場景”:

初始化場景 = 建立三個三角形的Geometry資料 |>  建立和初始化對應的VBO |> 設定相機的view matrix和projection matrix |> 設定清空顏色緩衝時的顏色值

下面分別實現子邏輯:

  • 建立三個三角形的Geometry資料

因為每個三角形的Geometry資料都一樣,所以在Utils.re中增加通用的createTriangleGeometryData函式:

let createTriangleGeometryData = () => {
  open Js.Typed_array;

  let vertices =
    Float32Array.make([|
      0.,
      0.5,
      0.0,
      (-0.5),
      (-0.5),
      0.0,
      0.5,
      (-0.5),
      0.0,
    |]);

  let indices = Uint16Array.make([|0, 1, 2|]);

  (vertices, indices);
};

這裡使用Reason提供的Js.Typed_array.Float32Array庫來操作Float32Array。

在Main.re的_init函式中,建立三個三角形的Geometry資料:

let (vertices1, indices1) = Utils.createTriangleGeometryData();
let (vertices2, indices2) = Utils.createTriangleGeometryData();
let (vertices3, indices3) = Utils.createTriangleGeometryData();
  • 建立和初始化對應的VBO

在Gl.re中定義FFI:

type bufferTarget =
  | ArrayBuffer
  | ElementArrayBuffer;
  
 type usage =
  | Static;
  
[@bs.send.pipe: webgl1Context] external createBuffer: buffer = "";

[@bs.get]
external getArrayBuffer: webgl1Context => bufferTarget = "ARRAY_BUFFER";

[@bs.get]
external getElementArrayBuffer: webgl1Context => bufferTarget =
  "ELEMENT_ARRAY_BUFFER";

[@bs.send.pipe: webgl1Context]
external bindBuffer: (bufferTarget, buffer) => unit = "";

[@bs.send.pipe: webgl1Context]
external bufferFloat32Data: (bufferTarget, Float32Array.t, usage) => unit =
  "bufferData";

[@bs.send.pipe: webgl1Context]
external bufferUint16Data: (bufferTarget, Uint16Array.t, usage) => unit =
  "bufferData";
  
[@bs.get] external getStaticDraw: webgl1Context => usage = "STATIC_DRAW";

因為每個三角形“建立和初始化VBO”的邏輯都一樣,所以在Utils.re中增加通用的initVertexBuffers函式:

let initVertexBuffers = ((vertices, indices), gl) => {
  let vertexBuffer = Gl.createBuffer(gl);

  Gl.bindBuffer(Gl.getArrayBuffer(gl), vertexBuffer, gl);

  Gl.bufferFloat32Data(
    Gl.getArrayBuffer(gl),
    vertices,
    Gl.getStaticDraw(gl),
    gl,
  );

  let indexBuffer = Gl.createBuffer(gl);

  Gl.bindBuffer(Gl.getElementArrayBuffer(gl), indexBuffer, gl);

  Gl.bufferUint16Data(
    Gl.getElementArrayBuffer(gl),
    indices,
    Gl.getStaticDraw(gl),
    gl,
  );

  (vertexBuffer, indexBuffer);
};

在Main.re的_init函式中,建立和初始化對應的VBO:

let (vertexBuffer1, indexBuffer1) =
Utils.initVertexBuffers((vertices1, indices1), gl);

let (vertexBuffer2, indexBuffer2) =
Utils.initVertexBuffers((vertices2, indices2), gl);

let (vertexBuffer3, indexBuffer3) =
Utils.initVertexBuffers((vertices3, indices3), gl);
  • 設定相機的view matrix和projection matrix

因為涉及到矩陣操作,並且該矩陣操作需要操作Vector,所以我們在src/中加入Matrix.re和Vector.re,增加對應的函式:

Matrix.re:

open Js.Typed_array;

let createIdentityMatrix = () =>
  Js.Typed_array.Float32Array.make([|
    1.,
    0.,
    0.,
    0.,
    0.,
    1.,
    0.,
    0.,
    0.,
    0.,
    1.,
    0.,
    0.,
    0.,
    0.,
    1.,
  |]);

let _getEpsilon = () => 0.000001;

let setLookAt =
    (
      (eyeX, eyeY, eyeZ) as eye,
      (centerX, centerY, centerZ) as center,
      (upX, upY, upZ) as up,
      resultFloat32Arr,
    ) =>
  Js.Math.abs_float(eyeX -. centerX) < _getEpsilon()
  && Js.Math.abs_float(eyeY -. centerY) < _getEpsilon()
  && Js.Math.abs_float(eyeZ -. centerZ) < _getEpsilon()
    ? resultFloat32Arr
    : {
      let (z1, z2, z3) as z = Vector.sub(eye, center) |> Vector.normalize;

      let (x1, x2, x3) as x = Vector.cross(up, z) |> Vector.normalize;

      let (y1, y2, y3) as y = Vector.cross(z, x) |> Vector.normalize;

      Float32Array.unsafe_set(resultFloat32Arr, 0, x1);
      Float32Array.unsafe_set(resultFloat32Arr, 1, y1);
      Float32Array.unsafe_set(resultFloat32Arr, 2, z1);
      Float32Array.unsafe_set(resultFloat32Arr, 3, 0.);
      Float32Array.unsafe_set(resultFloat32Arr, 4, x2);
      Float32Array.unsafe_set(resultFloat32Arr, 5, y2);
      Float32Array.unsafe_set(resultFloat32Arr, 6, z2);
      Float32Array.unsafe_set(resultFloat32Arr, 7, 0.);
      Float32Array.unsafe_set(resultFloat32Arr, 8, x3);
      Float32Array.unsafe_set(resultFloat32Arr, 9, y3);
      Float32Array.unsafe_set(resultFloat32Arr, 10, z3);
      Float32Array.unsafe_set(resultFloat32Arr, 11, 0.);
      Float32Array.unsafe_set(resultFloat32Arr, 12, -. Vector.dot(x, eye));
      Float32Array.unsafe_set(resultFloat32Arr, 13, -. Vector.dot(y, eye));
      Float32Array.unsafe_set(resultFloat32Arr, 14, -. Vector.dot(z, eye));
      Float32Array.unsafe_set(resultFloat32Arr, 15, 1.);

      resultFloat32Arr;
    };

let buildPerspective =
    ((fovy: float, aspect: float, near: float, far: float), resultFloat32Arr) => {
  Js.Math.sin(Js.Math._PI *. fovy /. 180. /. 2.) === 0.
    ? Utils.error("frustum should not be null") : ();

  let fovy = Js.Math._PI *. fovy /. 180. /. 2.;
  let s = Js.Math.sin(fovy);
  let rd = 1. /. (far -. near);
  let ct = Js.Math.cos(fovy) /. s;
  Float32Array.unsafe_set(resultFloat32Arr, 0, ct /. aspect);
  Float32Array.unsafe_set(resultFloat32Arr, 1, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 2, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 3, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 4, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 5, ct);
  Float32Array.unsafe_set(resultFloat32Arr, 6, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 7, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 8, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 9, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 10, -. (far +. near) *. rd);
  Float32Array.unsafe_set(resultFloat32Arr, 11, -1.);
  Float32Array.unsafe_set(resultFloat32Arr, 12, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 13, 0.);
  Float32Array.unsafe_set(resultFloat32Arr, 14, (-2.) *. far *. near *. rd);
  Float32Array.unsafe_set(resultFloat32Arr, 15, 0.);

  resultFloat32Arr;
};

Vector.re:

let dot = ((x, y, z), (vx, vy, vz)) => x *. vx +. y *. vy +. z *. vz;

let sub = ((x1, y1, z1), (x2, y2, z2)) => (x1 -. x2, y1 -. y2, z1 -. z2);

let scale = (scalar, (x, y, z)) => (x *. scalar, y *. scalar, z *. scalar);

let cross = ((x1, y1, z1), (x2, y2, z2)) => (
  y1 *. z2 -. y2 *. z1,
  z1 *. x2 -. z2 *. x1,
  x1 *. y2 -. x2 *. y1,
);

let normalize = ((x, y, z)) => {
  let d = Js.Math.sqrt(x *. x +. y *. y +. z *. z);
  d === 0. ? (0., 0., 0.) : (x /. d, y /. d, z /. d);
};

在Main.re的_init函式中,設定固定相機的vMatrix和pMatrix:

let vMatrix =
Matrix.createIdentityMatrix()
|> Matrix.setLookAt((0., 0.0, 5.), (0., 0., (-100.)), (0., 1., 0.));

let pMatrix =
Matrix.createIdentityMatrix()
|> Matrix.buildPerspective((
     30.,
     (canvas##width |> Js.Int.toFloat)
     /. (canvas##height |> Js.Int.toFloat),
     1.,
     100.,
   ));

實現“設定清空顏色緩衝時的顏色值”

在Gl.re中定義FFI:

[@bs.send.pipe: webgl1Context]
external clearColor: (float, float, float, float) => unit = "";

在Main.re的_init函式中,設定清空顏色緩衝時的顏色值為黑色:

Gl.clearColor(0., 0., 0., 1., gl);

返回用於主迴圈的資料

在Main.re的_init函式中,將WebGL上下文、所有的program、所有的indices、所有的VBO、相機的view matrix和projection matrix返回,供主迴圈使用(只可讀):

(
    gl,
    (program1, program2),
    (indices1, indices2, indices3),
    (vertexBuffer1, indexBuffer1),
    (vertexBuffer2, indexBuffer2),
    (vertexBuffer3, indexBuffer3),
    (vMatrix, pMatrix),
);

實現_loop

_init函式實現完畢,接下來實現_loop函式,它的總體設計為:

type _loop = data => int;
let rec _loop = (data) => 
    requestAnimationFrame((time:int) => {
        _loopBody(data);
        _loop(data) |> ignore;    
    });

實現“主迴圈”

需要呼叫window.requestAnimationFrame來開啟主迴圈。

在DomExtend.re中定義FFI:

[@bs.val] external requestAnimationFrame: (float => unit) => int = "";

然後定義空函式_loopBody,實現_loop的主迴圈,並通過編譯檢查:

let _loopBody = (data) => ();

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

實現“clearCanvas”

接下來我們要實現_loopBody,它的總體設計為:

type _loopBody = data => unit;
let _loopBody = (data) => {
    data
    |> _clearCanvas
    |> _render
};

我們首先實現_clearCanvas函式,為此需要在Gl.re中定義FFI:

[@bs.send.pipe: webgl1Context] external clear: int => unit = "";

[@bs.get]
external getColorBufferBit: webgl1Context => int = "COLOR_BUFFER_BIT";

[@bs.get]
external getDepthBufferBit: webgl1Context => int = "DEPTH_BUFFER_BIT";

然後在Main.re中實現_clearCanvas函式:

let _clearCanvas =
    (
      (
        gl,
        (program1, program2),
        (indices1, indices2, indices3),
        (vertexBuffer1, indexBuffer1),
        (vertexBuffer2, indexBuffer2),
        (vertexBuffer3, indexBuffer3),
        (vMatrix, pMatrix),
      ) as data,
    ) => {
  Gl.clear(Gl.getColorBufferBit(gl) lor Gl.getDepthBufferBit(gl), gl);

  data;
};

實現“_render”

_render的總體設計為:

type _render = data => unit;
let _render = (data) => {
    設定WebGL狀態 
    |> 繪製三個三角形
};

下面分別實現:

設定WebGL狀態

在Gl.re中定義FFI:

[@bs.get] external getDepthTest: webgl1Context => int = "DEPTH_TEST";

[@bs.send.pipe: webgl1Context] external enable: int => unit = "";

[@bs.get] external getCullFace: webgl1Context => int = "CULL_FACE";

[@bs.send.pipe: webgl1Context] external cullFace: int => unit = "";

[@bs.get] external getBack: webgl1Context => int = "BACK";

在Main.re的_render函式中設定WebGL狀態,開啟深度測試和背面剔除:

Gl.enable(Gl.getDepthTest(gl), gl);

Gl.enable(Gl.getCullFace(gl), gl);
Gl.cullFace(Gl.getBack(gl), gl);

繪製第一個三角形

在_render函式中需要繪製三個三角形。
我們來細化“繪製每個三角形”:

繪製每個三角形 = 使用對應的Program |> 傳遞三角形的頂點資料 |> 傳遞相機資料 |> 傳遞三角形的位置資料 |> 傳遞三角形的顏色資料 |> 繪製三角形

下面先繪製第一個三角形,分別實現它的子邏輯:

  • 使用對應的Program

在Gl.re中定義FFI:

[@bs.send.pipe: webgl1Context] external useProgram: program => unit = "";

在Main.re的_render函式中使用program1:

Gl.useProgram(program1, gl);
  • 傳遞三角形的頂點資料

在Gl.re中定義FFI:

type attributeLocation = int;

[@bs.send.pipe: webgl1Context]
external getAttribLocation: (program, string) => attributeLocation = "";

[@bs.send.pipe: webgl1Context]
external vertexAttribPointer:
  (attributeLocation, int, int, bool, int, int) => unit =
  "";

[@bs.send.pipe: webgl1Context]
external enableVertexAttribArray: attributeLocation => unit = "";

[@bs.get] external getFloat: webgl1Context => int = "FLOAT";

因為“傳遞頂點資料”是通用邏輯,所以在Utils.re中增加sendAttributeData函式:
首先判斷program對應的GLSL中是否有vertices對應的attribute:a_position;
如果有,則開啟vertices對應的VBO;否則,丟擲錯誤資訊。

相關程式碼如下:

let sendAttributeData = (vertexBuffer, program, gl) => {
  let positionLocation = Gl.getAttribLocation(program, "a_position", gl);

  positionLocation === (-1)
    ? error({j|Failed to get the storage location of a_position|j}) : ();

  Gl.bindBuffer(Gl.getArrayBuffer(gl), vertexBuffer, gl);

  Gl.vertexAttribPointer(
    positionLocation,
    3,
    Gl.getFloat(gl),
    false,
    0,
    0,
    gl,
  );
  Gl.enableVertexAttribArray(positionLocation, gl);
};

在Main.re的_render函式中呼叫sendAttributeData:

Utils.sendAttributeData(vertexBuffer1, program1, gl);
  • 傳遞相機資料

在Gl.re中定義FFI:

type uniformLocation;

[@bs.send.pipe: webgl1Context]
external uniformMatrix4fv: (uniformLocation, bool, Float32Array.t) => unit =
  "";
  
[@bs.send.pipe: webgl1Context]
external getUniformLocation: (program, string) => Js.Null.t(uniformLocation) =
  "";

因為“傳遞相機資料”是通用邏輯,所以在Utils.re中增加sendCameraUniformData函式:
首先判斷program對應的GLSL中是否有view matrix對應的uniform:u_vMatrix和projection matrix對應的uniform:u_pMatrix;
如果有,則傳遞對應的矩陣資料;否則,丟擲錯誤資訊。

相關程式碼如下:

//與error函式的不同是沒有使用ignore來忽略返回值
let errorAndReturn = msg => Js.Exn.raiseError(msg);

let _unsafeGetUniformLocation = (program, name, gl) =>
  switch (Gl.getUniformLocation(program, name, gl)) {
  | pos when !Js.Null.test(pos) => Js.Null.getUnsafe(pos)
  //這裡需要有返回值
  | _ => errorAndReturn({j|$name uniform not exist|j})
  };

let sendCameraUniformData = ((vMatrix, pMatrix), program, gl) => {
  let vMatrixLocation = _unsafeGetUniformLocation(program, "u_vMatrix", gl);
  let pMatrixLocation = _unsafeGetUniformLocation(program, "u_pMatrix", gl);

  Gl.uniformMatrix4fv(vMatrixLocation, false, vMatrix, gl);
  Gl.uniformMatrix4fv(pMatrixLocation, false, pMatrix, gl);
};

在Main.re的_render函式中呼叫sendCameraUniformData:

Utils.sendCameraUniformData((vMatrix, pMatrix), program1, gl);
  • “傳遞三角形的位置資料”以及“傳遞三角形的顏色資料”

在Gl.re中定義FFI:

[@bs.send.pipe: webgl1Context]
external uniform3f: (uniformLocation, float, float, float) => unit = "";

因為這兩個邏輯都是傳遞GLSL的uniform資料,所以放在一個函式中;又因為使用不同GLSL的三角形,傳遞的顏色資料不一樣,所以需要在Utils.re中,增加sendModelUniformData1、sendModelUniformData2函式,分別對應第一組和第二組GLSL。第一個和第三個三角形使用sendModelUniformData1,第二個三角形使用sendModelUniformData2。
這兩個函式都需要判斷GLSL中是否有model matrix對應的uniform:u_mMatrix和顏色對應的uniform;
如果有,則傳遞對應的資料;否則,丟擲錯誤資訊。

相關程式碼如下:

let _sendColorData = ((r, g, b), gl, colorLocation) =>
  Gl.uniform3f(colorLocation, r, g, b, gl);

let sendModelUniformData1 = ((mMatrix, color), program, gl) => {
  let mMatrixLocation = _unsafeGetUniformLocation(program, "u_mMatrix", gl);
  let colorLocation = _unsafeGetUniformLocation(program, "u_color0", gl);

  Gl.uniformMatrix4fv(mMatrixLocation, false, mMatrix, gl);
  _sendColorData(color, gl, colorLocation);
};

let sendModelUniformData2 = ((mMatrix, color1, color2), program, gl) => {
  let mMatrixLocation = _unsafeGetUniformLocation(program, "u_mMatrix", gl);
  let color1Location = _unsafeGetUniformLocation(program, "u_color0", gl);
  let color2Location = _unsafeGetUniformLocation(program, "u_color1", gl);

  Gl.uniformMatrix4fv(mMatrixLocation, false, mMatrix, gl);
  _sendColorData(color1, gl, color1Location);
  _sendColorData(color2, gl, color2Location);
};

在Matrix.re中增加setTranslation函式:

let setTranslation = ((x, y, z), resultFloat32Arr) => {
  Float32Array.unsafe_set(resultFloat32Arr, 12, x);
  Float32Array.unsafe_set(resultFloat32Arr, 13, y);
  Float32Array.unsafe_set(resultFloat32Arr, 14, z);

  resultFloat32Arr;
};

在Main.re的_render函式中呼叫sendModelUniformData1:

Utils.sendModelUniformData1(
    (
      Matrix.createIdentityMatrix() |> Matrix.setTranslation((0.75, 0., 0.)),
      (1., 0., 0.),
    ),
    program1,
    gl,
);
  • 繪製三角形

在Gl.re中定義FFI:

[@bs.get] external getTriangles: webgl1Context => int = "TRIANGLES";

[@bs.get] external getUnsignedShort: webgl1Context => int = "UNSIGNED_SHORT";

[@bs.send.pipe: webgl1Context]
external drawElements: (int, int, int, int) => unit = "";

在Main.re的_render函式中,繫結indices1對應的VBO,使用drawElements繪製第一個三角形:

Gl.bindBuffer(Gl.getElementArrayBuffer(gl), indexBuffer1, gl);

Gl.drawElements(
    Gl.getTriangles(gl),
    indices1 |> Js.Typed_array.Uint16Array.length,
    Gl.getUnsignedShort(gl),
    0,
    gl,
);

繪製第二個和第三個三角形

與繪製第一個三角形類似,在Main.re的_render函式中,使用對應的program,傳遞相同的相機資料,呼叫對應的Utils.sendModelUniformData1或sendModelUniformData2函式、繫結對應的VBO,來繪製第二個和第三個三角形。

Main.re的_render函式的相關程式碼如下:

//繪製第二個三角形

Gl.useProgram(program2, gl);

Utils.sendAttributeData(vertexBuffer2, program2, gl);

Utils.sendCameraUniformData((vMatrix, pMatrix), program2, gl);

Utils.sendModelUniformData2(
  (
    Matrix.createIdentityMatrix() |> Matrix.setTranslation(((-0.), 0., 0.5)),
    (0., 0.8, 0.),
    (0., 0.5, 0.),
  ),
  program2,
  gl,
);

Gl.bindBuffer(Gl.getElementArrayBuffer(gl), indexBuffer2, gl);

Gl.drawElements(
  Gl.getTriangles(gl),
  indices2 |> Js.Typed_array.Uint16Array.length,
  Gl.getUnsignedShort(gl),
  0,
  gl,
);

//繪製第三個三角形

Gl.useProgram(program1, gl);

Utils.sendAttributeData(vertexBuffer3, program1, gl);

Utils.sendCameraUniformData((vMatrix, pMatrix), program1, gl);

Utils.sendModelUniformData1(
  (
    Matrix.createIdentityMatrix() |> Matrix.setTranslation(((-0.5), 0., (-2.))),
    (0., 0., 1.),
  ),
  program1,
  gl,
);

Gl.bindBuffer(Gl.getElementArrayBuffer(gl), indexBuffer3, gl);

Gl.drawElements(
  Gl.getTriangles(gl),
  indices3 |> Js.Typed_array.Uint16Array.length,
  Gl.getUnsignedShort(gl),
  0,
  gl,
);

最終的分層和領域模型

如下圖所示:

總結

本文通過需求分析、總體設計和具體實現,實現了最小的3D程式,繪製了三角形。

但是,還有很多不足之處:
1、場景邏輯和WebGL API的呼叫邏輯混雜在一起
2、存在重複程式碼,如Utils的sendModelUniformData1和sendModelUniformData2有重複的模式
3、需要進行優化,如只需要傳遞一次相機資料、“使用getShaderParameter來檢查初始化Shader的正確性”降低了效能
4、_init傳遞給主迴圈的資料,作為函式的形參過於複雜

我們會在後面的文章中,解決這些問題。

本文完整程式碼地址

Book-Demo-Triangle Github