Hello WebGL

544 阅读23分钟

对比 Canvas

WebGLCanvas 是用于在网页中绘制图形的两种技术,但它们的功能和适用场景存在显著的差异。

我把 WebGLCanvas 视为对立面,旨在 对比两种绘图技术的差异

为此我先罗列两者 相同点,再审视两者 不同点

这样就更能理解将两者相互视为对立面的 原因 了。

相同点

image.png

不同点

技术基础

特性WebGLCanvas
定义基于 OpenGL ES3D 图形渲染 APIHTML5 提供的 2D 图形绘制工具
图形类型支持 3D2D 图形渲染专为 2D 图形绘制设计,有限的 3D 模拟能力
硬件加速利用 GPU 进行硬件加速,性能高,适合复杂场景通常基于 CPU 渲染,性能相对较低

编程模型

特性WebGLCanvas
渲染方式依赖低级图形 API,需要编写 GLSL 着色器和控制渲染管线提供简单的 2D 绘图 API,例如 fillRect
开发复杂度较高,需要较多的数学和计算机图形学知识较低,上手容易,适合快速实现简单绘图
可扩展性高度灵活,支持复杂 3D 模型和交互效果灵活性有限,适合绘制简单图形或动画

应用场景

场景WebGLCanvas
3D游戏和虚拟现实非常适合,高性能渲染 3D 场景不适合,性能不足且无法直接支持 3D 图形
数据可视化可用于复杂的 3D 数据可视化(例如地图、科学数据)适用于简单的 2D 数据图表
动画和游戏用于高质量、复杂动画或高性能游戏适用于简单的 2D 游戏或低要求动画
图像处理支持纹理和着色器,可实现复杂的图像特效适合简单图形操作(如裁剪、合成)

性能比较

特性WebGLCanvas
渲染性能高性能,依赖 GPU,能处理大量图形计算性能一般,依赖 CPU,适合中小规模绘图任务
设备要求需要支持 WebGLGPU 和 驱动程序兼容性好,支持所有现代浏览器和设备

使用示例

<canvas id="canvas" width="200" height="200"></canvas>

<script>
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d'); // 明显差异之处

  ctx.fillStyle = 'red';
  ctx.fillRect(50, 50, 100, 100);
</script>
<canvas id="webglCanvas"></canvas>

<script>
  const canvas = document.getElementById('webglCanvas');
  const gl = canvas.getContext('webgl'); // 明显差异之处

  const vertices = new Float32Array([
      0.0, 1.0,
     -1.0, -1.0,
      1.0, -1.0,
  ]);

  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

  const vertexShaderCode = `
      attribute vec2 coordinates;
      void main() {
          gl_Position = vec4(coordinates, 0.0, 1.0);
      }
  `;
  const vertexShader = gl.createShader(gl.VERTEX_SHADER);
  gl.shaderSource(vertexShader, vertexShaderCode);
  gl.compileShader(vertexShader);

  const fragmentShaderCode = `
      void main() {
          gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
      }
  `;
  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(fragmentShader, fragmentShaderCode);
  gl.compileShader(fragmentShader);

  const shaderProgram = gl.createProgram();
  gl.attachShader(shaderProgram, vertexShader);
  gl.attachShader(shaderProgram, fragmentShader);
  gl.linkProgram(shaderProgram);
  gl.useProgram(shaderProgram);

  const coord = gl.getAttribLocation(shaderProgram, 'coordinates');
  gl.vertexAttribPointer(coord, 2, gl.FLOAT, false, 0, 0);
  gl.enableVertexAttribArray(coord);

  gl.clearColor(0.0, 0.0, 0.0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
</script>

开始

准备上下文

开始使用 WebGL,我们需要做些准备:

  1. 3D 渲染所需的 HTML 结构,以及控制渲染逻辑的 JavaScript
  2. 获取 WebGL 上下文 WebGLRenderingContext
<canvas id="glcanvas" width="640" height="480"></canvas>

<script>
function main() {
  const canvas = document.querySelector("#glcanvas"); // 获取 canvas 容器
  const gl = canvas.getContext("webgl"); // 初始化 WebGL 上下文

  if (!gl) return;// 确认 WebGL 支持性

  gl.clearColor(0.0, 0.0, 0.0, 1.0); // 使用完全不透明的黑色清除所有图像
  gl.clear(gl.COLOR_BUFFER_BIT); // 用上面指定的颜色清除缓冲区
} // 从这里开始

main()
</script>

一旦创建 WebGL 上下文创建成功,你就可以在这个上下文里渲染画图

WebGL 绘制场景的本质是通过 GPU(图形处理单元)执行一系列图形渲染操作,最终将 3D2D 场景转换为 2D 图像并显示在屏幕上

坐标系

WebGL 使用的是右手坐标系:X 轴-拇指方向、Y 轴-食指方向、Z轴-中指方向

类型化数组

// 仅罗列部分。如下
const int8Array = new Int8Array(3) // 8 位有符整数,每个元素占 1 个字节
const uint8Array = new Uint8Array(3) // 8 位无符整数,每个元素占 1 个字节
const float32Array = new Float32Array(3) // 32 位浮点数,每个元素占 4 字节
const float64Array = new Float64Array(3) // 64 位浮点数,每个元素占 8 个字节

console.log(
  int8Array.BYTES_PER_ELEMENT, // 每个元素占 1 个字节
  uint8Array.BYTES_PER_ELEMENT, // 每个元素占 1 个字节
  float32Array.BYTES_PER_ELEMENT, // 每个元素占 4 个字节
  float64Array.BYTES_PER_ELEMENT, // 每个元素占 8 个字节
  int8Array.buffer.byteLength, // 当前占总字节数 1 * 3
  uint8Array.buffer.byteLength, // 当前占总字节数 1 * 3
  float32Array.buffer.byteLength, // 当前占总字节数 4 * 3
  float64Array.buffer.byteLength, // 当前占总字节数 8 * 3
)

流程

WebGL 绘制场景的本质是通过 GPU(图形处理单元)执行一系列图形渲染操作,最终将 3D2D 场景转换为 2D 图像并显示在屏幕上。具体来说,WebGL 使用图形管线(Graphics Pipeline)来处理数据,从而实现图形渲染。

图形管线的工作流程

图形管线是渲染图形的整个过程,包括一系列的图形处理阶段。在 WebGL 中,渲染的本质是通过管线将数据从“输入”转变为最终图像输出。图形管线通常分为以下几个主要阶段:

  • 顶点处理(Vertex Processing)
    • 顶点着色器(Vertex Shader)对输入的顶点数据进行处理,进行变换(如模型矩阵、视图矩阵、投影矩阵的乘法),并输出经过变换的顶点位置。这个过程可以包括顶点坐标的变换、光照计算、颜色、纹理坐标的计算等。
  • 图元组装(Primitive Assembly)
    • 顶点被组织成图元(如三角形、线段等)。WebGL 默认使用三角形作为基本的绘制单元。顶点着色器处理后,WebGL 会将这些顶点组合成几何图形,并准备好进入光栅化阶段。
  • 光栅化(Rasterization)
    • 光栅化是将图元(如三角形)转换为屏幕上的像素(片段)的过程。每个图元会被分解成一系列片段,代表最终图像中的像素。
  • 片段处理(Fragment Processing)
    • 片段着色器(Fragment Shader)会根据插值的数据(如颜色、纹理坐标等)计算每个片段的最终颜色。这个阶段通常处理纹理映射、颜色混合、光照等操作。
  • 输出合成(Framebuffer)
    • 最终计算出的片段颜色会被写入帧缓冲区(Framebuffer),并显示在屏幕上。这个阶段也可以进行深度测试、透明度处理、抗锯齿等操作。

WebGL 绘制场景的核心其实就是如何组织和处理数据。场景中的每个物体都可以通过几何数据(顶点)和材质数据(颜色、纹理、光照等)来表示。然后,通过图形管线的各个阶段,最终将这些数据转化为图像。

  • 顶点数据:包含场景中物体的形状、大小、位置等信息,通常是一个多边形网格(mesh)的一组顶点。
  • 材质和纹理数据:决定了物体的外观,包括颜色、纹理、光照等。纹理可以通过图像映射到物体的表面。
  • 变换数据:描述物体的位移、旋转、缩放,常用模型矩阵、视图矩阵和投影矩阵进行计算。

三维场景的渲染过程

WebGL 绘制三维场景的过程实际上就是将三维物体通过不同的变换和着色操作,最终映射到二维屏幕上的过程。这通常涉及以下几个步骤:

  • 模型变换:将物体从局部坐标系转换到世界坐标系。
  • 视图变换:将物体从世界坐标系转换到相机坐标系,使得场景中的物体相对于视点的显示方式得以确定。
  • 投影变换:将物体从三维空间投影到二维屏幕上。投影可以是正交投影或透视投影。
  • 光照与材质:应用不同的光照模型(如 Phong、Blinn-Phong 等)来计算物体表面的光照效果。
  • 后处理效果:包括抗锯齿、阴影、反射、折射、模糊等效果的计算。

WebGL 的渲染过程实际上是 GPU 在大量并行计算中处理数据的结果。顶点着色器和片段着色器是高度并行的,意味着每个顶点和片段可以独立计算,不会互相干扰。GPU 的强大并行处理能力使得 WebGL 可以高效地渲染复杂的场景。

WebGL 不仅仅是静态渲染的工具,还可以处理动态场景和用户交互。比如通过更新物体的变换矩阵,实时响应用户的输入(鼠标、键盘等)来动态改变场景中的内容。场景的每个更新,都会涉及到重新计算顶点变换、光照和片段着色,最终更新渲染输出。

着色器

着色器是 使用 GLSLOpenGL ES Shading Language)编写的程序, 来描述如何渲染图形 。

它携带着绘制形状的顶点信息、以及构造绘制在屏幕上像素的所需数据!

换句话说,它负责 记录像素点的位置和颜色!

attribute vec4 a_Position;  // 顶点位置
attribute vec4 a_Color;     // 顶点颜色
varying vec4 v_Color;       // 用于传递给片段着色器的颜色数据

void main() {
    gl_Position = a_Position;  // 变换顶点位置
    v_Color = a_Color;         // 将颜色传递到片段着色器
}
precision mediump float;  // 设置浮点精度
varying vec4 v_Color;     // 接受从顶点着色器传递过来的颜色数据

void main() {
    gl_FragColor = v_Color;  // 将颜色赋值给最终输出的像素颜色
}

创建着色器

在 WebGL 中,创建着色器的一般步骤如下

  1. 创建着色器对象:使用 gl.createShader() 创建一个空的着色器对象。
  2. 编译着色器:使用 gl.shaderSource() 为着色器对象提供源码,然后用 gl.compileShader() 编译着色器。
  3. 检查编译状态:通过 gl.getShaderParameter()gl.getShaderInfoLog() 检查编译是否成功。
  4. 创建程序对象:用 gl.createProgram() 创建一个 WebGL 程序。
  5. 附加着色器:使用 gl.attachShader() 把顶点和片段着色器附加到程序对象上。
  6. 链接程序:使用 gl.linkProgram() 链接着色器程序。
  7. 使用程序:通过 gl.useProgram() 激活着色器程序。

着色器示例

function createShader(gl, source, type) { // 创建着色器函数
  const shader = gl.createShader(type)

  gl.shaderSource(shader, source)
  gl.compileShader(shader)

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return null

  return shader // gl.getShaderInfoLog(shader)
}

function createProgram(gl, vertexShader, fragmentShader) { // 创建程序
  const program = gl.createProgram()

  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null

  return program // gl.getProgramInfoLog(program)
}

function useShader(canvas) { // 暴露函数
  const gl = canvas.getContext('webgl')
  const vertexShaderSource = `
    attribute vec4 a_Position;
    attribute vec4 a_Color;
    varying vec4 v_Color;

    void main() {
      gl_Position = a_Position;
      v_Color = a_Color;
    }
  ` // 顶点着色器源码 glsl
  const fragmentShaderSource = `
    precision mediump float;
    varying vec4 v_Color;

    void main() {
      gl_FragColor = v_Color;
    }
  ` // 片段着色器源码 glsl

  const vertexShader = createShader(gl, vertexShaderSource, gl.VERTEX_SHADER)
  const fragmentShader = createShader(gl, fragmentShaderSource, gl.FRAGMENT_SHADER)
  const program = createProgram(gl, vertexShader, fragmentShader)

  gl.useProgram(program)
}

着色器语法

<canvas id="canvas" width="300" height="300"></canvas>

<!-- 顶点着色器:写入 glsl 代码 -->
<script id="vertex-shader" type="x-shader/x-vertex">
  void main() {
    gl_PointSize = 10.0;
    gl_Position = vec4(0, 0, 0, 1);
  }
</script>

<!-- 片段着色器:写入 glsl 代码 -->
<script id="fragment-shader" type="x-shader/x-fragment">
  void main() {
    gl_FragColor = vec4(1, 0, 0, 1);
  }
</script>

<!-- 渲染控制逻辑 js 代码 -->
<script type="module">
  const canvas = document.getElementById('canvas')
  const gl = canvas.getContext('webgl')
  const vertexSource = document.getElementById('vertex-shader').innerText;
  const fragmentSource = document.getElementById('fragment-shader').innerText;

  console.log('逻辑代码执行结果', vertexSource, fragmentSource)
</script>

<!-- 自定义 css 样式 -->
<style>
  #canvas { border: solid 1px red; border-radius: 4px; }
</style>

图元

图元(Primitive)WebGL 图形绘制的基本单元!

绘制方法

// https://developer.mozilla.org/docs/Web/API/WebGLRenderingContext/drawArrays
drawArrays(mode: GLenum, first: GLint, count: GLsizei): void;
// https://developer.mozilla.org/docs/Web/API/WebGLRenderingContext/drawElements
drawElements(mode: GLenum, count: GLsizei, type: GLenum, offset: GLintptr): void;

点(Point)

点(Point)WebGL 中最基础的图元,掌握绘制 的基本流程,是快速熟悉 WebGL 绘图步骤的最快方式。

绘制流程

实现代码

<!-- 顶点着色器:写入 glsl 代码 -->
<script id="vertex-shader" type="x-shader/x-vertex">
   void main() {
      gl_PointSize = 10.0;
      gl_Position = vec4(0, 0, 0, 1);
   }
</script>

<!-- 片段着色器:写入 glsl 代码 -->
<script id="fragment-shader" type="x-shader/x-fragment">
  void main() {
     gl_FragColor = vec4(0, 0, 2, 1);
  }
</script>
function createGLShader(gl, type, source) {
  const shader = gl.createShader(type) // 创建着色器对象

  gl.shaderSource(shader, source) // 将源代码发送到着色器
  gl.compileShader(shader) // 编译着色器源码

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    gl.deleteShader(shader)

    return null
  } // 检查着色器是否编译成功

  return shader
}
function createGLProgram(gl, glsl) {
  const program = gl.createProgram() // 创建 WebGL 程序对象
  const vertexShader = createGLShader(gl, gl.VERTEX_SHADER, glsl.vsCode)
  const fragmentShader = createGLShader(gl, gl.FRAGMENT_SHADER, glsl.fsCode)

  gl.attachShader(program, vertexShader) // 把 顶点着色器 附加到程序对象上
  gl.attachShader(program, fragmentShader) // 把 片段着色器 附加到程序对象上
  gl.linkProgram(program) // 链接程序
  gl.useProgram(program) // 激活程序

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null

  return program
} 
function clearBeforeRender(gl) {
  if (!gl) return

  gl.clearColor(0.0, 0.0, 0.0, 1.0) // Clear to black, fully opaque
  gl.clearDepth(1.0) // Clear everything
  gl.enable(gl.DEPTH_TEST) // Enable depth testing
  gl.depthFunc(gl.LEQUAL) // Near things obscure far things
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
}
function drawPoint(gl) {
  gl.drawArrays(gl.POINTS, 0, 1)
}

向 three.js 一样设计

抛出问题我们是否可以重新组织下代码,使其富有设计感呢 ???

给出答案: 当然可以!那就完全效仿 three.js 的设计,为 webgl 绘图逻辑,按需设计 渲染器、摄像机、场景 等基本单元。

设计出:渲染器 WebGLRenderer 如下

import GLShader from './GLShader.js'

export default class WebGLRenderer {
  constructor({ canvas } = {}) {
    this.gl = canvas.getContext('webgl')
    this.glShader = new GLShader(this.gl)
  }

  // 透过摄像机渲染场景
  render() {
    this.clearBeforeRender()
    this.drawPoint()
  }

  // 在渲染前,清空画布
  clearBeforeRender() {
    this.gl.clearColor(0, 0, 0, 1) // 指定清除缓冲时的颜色值
    this.gl.clear(this.gl.COLOR_BUFFER_BIT) // 清除缓冲区
  }

  // 绘制图元
  drawPoint() {
    this.gl.drawArrays(this.gl.POINTS, 0, 1)
  }
}

设计出:着色器 GLShader 如下

import GLProgram from '@/components/webgl/utils/GLProgram.js'

export default class GLShader {
  constructor(gl) {
    this.gl = gl
    this.glsl = this.createGLSLSource() // 着色器源码片段
    this.glProgram = new GLProgram(this.gl, this.glsl)// 着色器程序对象
  }

  createGLSLSource() {
    return {
      vsCode: `
        void main() {
          gl_PointSize = 10.0;
          gl_Position = vec4(0, 0, 0, 1);
        }
      `, // 顶点着色器源码
      fsCode: `
        void main() {
          gl_FragColor = vec4(0, 0, 2, 1);
        }
      ` // 片段着色器源码
    }
  }
}

设计出:程序 GLProgram 如下

// 着色器程序对象
export default class GLProgram {
  constructor(gl, glsl = { vsCode: '', fsCode: '' }) {
    this.gl = gl
    this.glsl = glsl

    return this.#createGLProgram()
  }

  // 初始化 WebGLProgram 程序
  #createGLProgram() {
    const program = this.gl.createProgram() // 创建 WebGL 程序对象
    const vertexShader = this.#createGLShader(
      this.gl.VERTEX_SHADER,
      this.glsl.vsCode
    )
    const fragmentShader = this.#createGLShader(
      this.gl.FRAGMENT_SHADER,
      this.glsl.fsCode
    )

    this.gl.attachShader(program, vertexShader) // 把 顶点着色器 附加到程序对象上
    this.gl.attachShader(program, fragmentShader) // 把 片段着色器 附加到程序对象上
    this.gl.linkProgram(program) // 链接程序
    this.gl.useProgram(program) // 激活程序

    if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
      return null
    }

    return program
  }

  // 创建 指定类型的 着色器
  #createGLShader(type, source) {
    const shader = this.gl.createShader(type) // 创建着色器对象

    this.gl.shaderSource(shader, source) // 将源代码发送到着色器
    this.gl.compileShader(shader) // 编译着色器源码

    if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
      this.gl.deleteShader(shader)
      return null
    } // 检查着色器是否编译成功

    return shader
  }
}

设计出:画布应用结构 如下

<script setup>
import { ref, onMounted } from 'vue'
import WebGLRenderer from './systems/WebGLRenderer.js'

const canvasEl = ref()

function drawBasePoint() {
  const renderer = new WebGLRenderer({ canvas: canvasEl.value }) // 渲染器

  renderer.render()
}

onMounted(() => {
  drawBasePoint()
})
</script>

<template>
  <canvas ref="canvasEl" width="320" height="320"></canvas>
</template>

写在后面: 对比代码直接实现逻辑,我们效仿 three.js 设计出的代码,具备:

  1. 符合模块化设计思想
  2. 符合面向对象设计思想
  3. 使得 webgl 的绘制逻辑,更清晰明了

因此!在后文的例子中,都将采用 three.js 设计,编写代码示例~~~

线(Line)

线(Line)WebGL 中基础的图元,本节我们尝试绘制一条最简单的 红线

绘制流程

实现代码

本节,仅展示 核心代码片段

import GLShader from './GLShader.js'

export default class WebGLRenderer {
  constructor({ canvas } = {}) {
    this.gl = canvas.getContext('webgl')
    this.glShader = new GLShader(this.gl)
  }

  // 透过摄像机渲染场景
  render() {
    this.clearBeforeRender()
    this.drawLine()
  }

  // 在渲染前,清空画布
  clearBeforeRender() {
    this.gl.clearColor(0, 0, 0, 1) // 指定清除缓冲时的颜色值
    this.gl.clear(this.gl.COLOR_BUFFER_BIT) // 清除缓冲区
  }

  // 绘制图元
  drawLine() {
    const vertices = new Float32Array([
      -0.5, 0.5, // 点 A
      0.5, -0.5, // 点 B
    ]) // 定义顶点数据

     // 创建一个新的缓冲区对象,存储顶点数据
    const buffer = this.gl.createBuffer()
    const posLocation = this.glShader.getLocationMap()

     // 将缓冲区对象绑定到指定目标
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer)

    // 向目标缓冲对象写入顶点数据
    this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW)

     // 从缓冲区对象中提取顶点属性数据,并将其传递给着色器程序中的顶点着色器
    this.gl.vertexAttribPointer(
      posLocation.aPosition,
      2,
      this.gl.FLOAT,
      false,
      2 * Float32Array.BYTES_PER_ELEMENT,
      0
    )

    // 启用顶点属性数据能被着色器访问
    this.gl.enableVertexAttribArray(posLocation.aPosition)

    this.gl.drawArrays(this.gl.LINES, 0, 2)
  }
}
import GLProgram from '@/components/webgl/utils/GLProgram.js'

export default class GLShader {
  constructor(gl) {
    this.gl = gl
    this.glsl = this.createGLSLSource() // 着色器源码片段
    this.glProgram = new GLProgram(this.gl, this.glsl) // 着色器程序对象
  }

  // 编写着色器 GLSL 源代码
  createGLSLSource() {
    return {
      vsCode: `
        attribute vec2 aPosition;
        varying vec4 vColor;

        void main() {
          gl_Position = vec4(aPosition, 0, 1);

          vColor = vec4(1, 0, 0, 1);
        }
      `, // 顶点着色器源码
      fsCode: `
        precision highp float;
        varying vec4 vColor;

        void main() {
          gl_FragColor = vColor;
        }
      ` // 片段着色器源码
    }
  }

  // 获取顶点属性在着色器程序中的位置映射
  getLocationMap() {
    const aPosition = this.gl.getAttribLocation(this.glProgram, 'aPosition')

    return {
      aPosition
    }
  }
}

面(Plane)

面(Plane)WebGL 中基础的图元

三角形

三角形(Triangle)面(Plane)体(Solid) 的基本组成图元。

下面这张图,可以最直观、最形象地解释这句话~

绘制流程

聪明的你,估计已经发现绘制流程怎么和线的绘制流程一样呢 ???

-= 没错!是的 =-

实现代码

import GLShader from './GLShader.js'

export default class WebGLRenderer {
  constructor({ canvas } = {}) {
    this.gl = canvas.getContext('webgl')
    this.glShader = new GLShader(this.gl)
  }

  // 透过摄像机渲染场景
  render() {
    this.clearBeforeRender()
    this.drawTriangle()
  }

  // 在渲染前,清空画布
  clearBeforeRender() {
    this.gl.clearColor(0, 0, 0, 1) // 指定清除缓冲时的颜色值
    this.gl.clear(this.gl.COLOR_BUFFER_BIT) // 清除缓冲区
  }

  // 绘制图元
  drawTriangle() {
    const vertices = new Float32Array([
      -0.5, -0.5, 1.0, 0.0, 0.0, 1.0, // 点 A
      0.5, -0.5, 0.0, 1.0, 0.0, 1.0, // 点 B
      0.0, 0.5, 0.0, 0.0, 1.0, 1.0, // 点 C
    ]) // 使用类型化数组,定义顶点数据

    const buffer = this.gl.createBuffer() // 创建缓冲区对象,存储顶点数据
    const posLocation = this.glShader.getLocationMap()

    // 将缓冲区对象绑定到指定目标
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer)

    // 向目标缓冲对象写入顶点数据
    this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW)

    this.gl.vertexAttribPointer(
      posLocation.aPosition,
      2, // 2 个元素代表一个位置点
      this.gl.FLOAT,
      false,
      6 * Float32Array.BYTES_PER_ELEMENT, // [步幅] 6 个元素为一步
      0 // [字节偏移量] 从 0 开始偏移
    ) // 从缓冲区对象中提取顶点属性数据,并将其传递给着色器程序中的顶点着色器
    this.gl.enableVertexAttribArray(posLocation.aPosition)
    // 启用顶点属性数据能比着色器访问

    this.gl.vertexAttribPointer(
      posLocation.aColor,
      4, // 4 个元素代表一个颜色点
      this.gl.FLOAT,
      false,
      6 * Float32Array.BYTES_PER_ELEMENT, // [步幅] 6 个元素为一步
      2 * Float32Array.BYTES_PER_ELEMENT, // [字节偏移量] 从第 3 个元素开始偏移
    )
    this.gl.enableVertexAttribArray(posLocation.aColor)

    // 绘制图元
    this.gl.drawArrays(this.gl.TRIANGLES, 0, 3)
  }
}
import GLProgram from '@/components/webgl/utils/GLProgram.js'

export default class GLShader {
  constructor(gl) {
    this.gl = gl
    this.glsl = this.createGLSLSource() // 着色器源码片段
    this.glProgram = new GLProgram(this.gl, this.glsl) // 着色器程序对象
  }

  // 编写着色器 GLSL 源代码
  createGLSLSource() {
    return {
      vsCode: `
        attribute vec2 aPosition;
        attribute vec4 aColor;
        varying vec4 vColor;

        void main() {
          gl_Position = vec4(aPosition, 0, 1);

          vColor = aColor;
        }
      `, // 顶点着色器源码
      fsCode: `
        precision highp float;
        varying vec4 vColor;

        void main() {
          gl_FragColor = vColor;
        }
      ` // 片段着色器源码
    }
  }

  getLocationMap() {
    const aColor = this.gl.getAttribLocation(this.glProgram, 'aColor')
    const aPosition = this.gl.getAttribLocation(this.glProgram, 'aPosition')

    return {
      aColor,
      aPosition
    }
  }
}

体(Solid)

正方体

绘制流程

同三角形

实现代码

import { mat4 } from 'gl-matrix'
import GLShader from './GLShader.js'
import { clearBeforeRender } from '@/components/webgl/utils/index.js'

export default class WebGLRenderer {
  constructor({ canvas } = {}) {
    this.gl = canvas.getContext('webgl')
    this.glShader = new GLShader(this.gl)
  }

  // 透过摄像机渲染场景
  render() {
    clearBeforeRender(this.gl)
    this.drawCubeReal()
  }

  // 绘制图元
  drawCubeReal() {
    const buffer_data = new Float32Array([
      1.0, 1.0, 1.0,  -1.0, 1.0, 1.0,  -1.0,-1.0, 1.0,   1.0,-1.0, 1.0,
      1.0, 1.0, 1.0,   1.0,-1.0, 1.0,   1.0,-1.0,-1.0,   1.0, 1.0,-1.0, 
      1.0, 1.0, 1.0,   1.0, 1.0,-1.0,  -1.0, 1.0,-1.0,  -1.0, 1.0, 1.0,
      -1.0, 1.0, 1.0,  -1.0, 1.0,-1.0,  -1.0,-1.0,-1.0,  -1.0,-1.0, 1.0,
      -1.0,-1.0,-1.0,   1.0,-1.0,-1.0,   1.0,-1.0, 1.0,  -1.0,-1.0, 1.0,
      1.0,-1.0,-1.0,  -1.0,-1.0,-1.0,  -1.0, 1.0,-1.0,   1.0, 1.0,-1.0
    ])

    const buffer = this.gl.createBuffer()

    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer)
    this.gl.bufferData(this.gl.ARRAY_BUFFER, buffer_data, this.gl.STATIC_DRAW)

    const color_data = new Float32Array([
      // v0-v1-v2-v3 front(blue)
      0.4, 0.4, 1.0,  0.4, 0.4, 1.0,  0.4, 0.4, 1.0,  0.4, 0.4, 1.0,
      // v0-v3-v4-v5 right(green)
      0.4, 1.0, 0.4,  0.4, 1.0, 0.4,  0.4, 1.0, 0.4,  0.4, 1.0, 0.4,
      // v0-v5-v6-v1 up(red)
      1.0, 0.4, 0.4,  1.0, 0.4, 0.4,  1.0, 0.4, 0.4,  1.0, 0.4, 0.4,
      // v1-v6-v7-v2 left
      1.0, 1.0, 0.4,  1.0, 1.0, 0.4,  1.0, 1.0, 0.4,  1.0, 1.0, 0.4,
      // v7-v4-v3-v2 down
      1.0, 1.0, 0.0,  1.0, 1.0, 0.0,  1.0, 1.0, 0.0,  1.0, 1.0, 0.0,
      // v4-v7-v6-v5 back
      0.4, 1.0, 1.0,  0.4, 1.0, 1.0,  0.4, 1.0, 1.0,  0.4, 1.0, 1.0
    ])
    const colorBuffer = this.gl.createBuffer()

    const indices = new Uint8Array([
      0, 1, 2,   0, 2, 3,    // front
      4, 5, 6,   4, 6, 7,    // right
      8, 9,10,   8,10,11,    // up
      12,13,14,  12,14,15,    // left
      16,17,18,  16,18,19,    // down
      20,21,22,  20,22,23     // back
    ])
    const indexBuffer = this.gl.createBuffer()
    this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
    this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, indices, this.gl.STATIC_DRAW)

    const attribLocationMap = this.glShader.getAttribLocationMap()

    const BYTES_SIZE = buffer_data.BYTES_PER_ELEMENT
    this.gl.vertexAttribPointer(
      attribLocationMap.pos,
      3,
      this.gl.FLOAT,
      false,
      BYTES_SIZE * 3,
      0
    )
    this.gl.enableVertexAttribArray(attribLocationMap.pos)

    const COLOR_BYTES_SIZE = color_data.BYTES_PER_ELEMENT
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, colorBuffer)
    this.gl.bufferData(this.gl.ARRAY_BUFFER, color_data, this.gl.STATIC_DRAW)
    this.gl.vertexAttribPointer(
      attribLocationMap.a_color,
      3,
      this.gl.FLOAT,
      false,
      COLOR_BYTES_SIZE * 3,
      0
    )
    this.gl.enableVertexAttribArray(attribLocationMap.a_color)

    const mx = mat4.create()
    const sceneMatrix = mat4.create()
    const cameraMatrix = mat4.create()

    mat4.perspective(cameraMatrix, 45, 1, 0.1, 100)
    mat4.lookAt(sceneMatrix, [2, 2, 3], [0, 0, 0], [0, 1, 0])
    mat4.multiply(mx, cameraMatrix, sceneMatrix)

    const uniformMatrixMap = this.glShader.getUniformMatrixMap()
    this.gl.uniformMatrix4fv(uniformMatrixMap.mx, false, mx)

    // 开启深度测试
    this.gl.enable(this.gl.DEPTH_TEST)

    this.gl.drawElements(
      this.gl.TRIANGLES,
      indices.length,
      this.gl.UNSIGNED_BYTE,
      0
    )
  }
}

变换

变换 是图形学中必不可少的内容,用 数学模型 描述变换,是 WebGL 绘制图形的重要部分。

数学

向量

矩阵运算

gl-matrix 库用来辅助执行 webgl 绘图逻辑中的矩阵操作

glmatrix.net/

npm i gl-matrix # 安装
import * as glMatix from 'gl-matrix' // 全量引入

import { mat4 } from 'gl-matrix' // 按需引入

模型矩阵 Model Matrix

  • 描述:用于描述每个物体在场景中的位置、旋转和缩放变换。
  • 作用:将物体从局部坐标系转换到世界坐标系
  • 特点:每个物体都有自己的模型矩阵,场景中不同的物体可以使用不同的模型矩阵。
mat4.translate(modelMatrix, modelMatrix, [x, y, z]);  // 平移
mat4.rotate(modelMatrix, modelMatrix, angle, [0, 1, 0]);  // 绕Y轴旋转
mat4.scale(modelMatrix, modelMatrix, [sx, sy, sz]);  // 缩放

视图矩阵 View Matrix

  • 描述:表示观察者(相机)的位置和方向。
  • 作用:将场景从世界坐标系转换到相机坐标系(观察者坐标系)。
  • 特点:整个场景共用一个视图矩阵,表示相机的视角。
mat4.lookAt(
  viewMatrix,
  [eyeX, eyeY, eyeZ],
  [centerX, centerY, centerZ],
  [upX, upY, upZ]
);

投影矩阵 Projection Matrix

  • 描述:用于定义相机的投影方式,将 3D 空间投影到 2D 平面上(屏幕)。
  • 作用:将场景从相机坐标系转换到裁剪坐标系
  • 分类透视投影:远近大小有透视效果,适合 3D 场景。

正交投影:大小不受距离影响,适合 2D 场景或工程图。

mat4.perspective(projectionMatrix, fov, aspect, near, far);  // 透视投影
mat4.ortho(projectionMatrix, left, right, bottom, top, near, far);  // 正交投影

综合矩阵 MVP

  • 视图矩阵表示相机。
  • 投影矩阵表示视角和投影方式。
  • 多个模型矩阵表示场景中的各个物体。

平移

webgl 如何用数学模型描述 平移 的呢?

答案是向量 OA 与偏移向量 delta 相加

attribute vec2 aPos;
attribute vec2 aPos1;

void main() {
  vec2 newPos = aPos + aPos1;
  gl_Position = vec4(newPos, 0, 1);
}

缩放

webgl 如何用数学模型描述 缩放 的呢?

答案是 * *向量 OA 与缩放向量 scaleVec 点乘

attribute vec2 aPos;

void main() {
  vec2 scaleVec = vec2(0.1, 1);
  vec2 newPos = scaleVec * aPos;

  gl_Position = vec4(newPos, 0, 1);
}

旋转

webgl 如何用数学模型描述 旋转 的呢?

答案是 * *向量 OA 与旋转变换矩阵 TT 相乘

  • 绕 X 轴旋转:正方向是顺时针,垂直于 X 轴的平面旋转。
  • 绕 Y 轴旋转:正方向是逆时针,垂直于 Y 轴的平面旋转。
  • 绕 Z 轴旋转:正方向是逆时针,垂直于 Z 轴的平面旋转。

纹理

在没有 (纹理 Texture) 之前,我们只能用 WebGL 绘制颜色(纯色、渐变色等),也就是每个像素点只有 RGBA 这样的色值!

纹理的出现就是将原来像素点上的 RGBA 色值,替换为纹理贴图的像素点

无论是 RGBA 色值,还是纹理贴图像素点,这对 WebGL 来讲,没有本质上的区别!

要实现纹理效果,我们将使用到一些新的 API

gl.activeTexture(type)

gl.bindTexture(type, texture)

使用 纹理 的基本步骤如下:

  1. 加载纹理资源
  2. 将纹理(Texture)映射到面(Plane)
  3. 更新着色器及程序
  4. 设置顶点数据及纹理坐标,绑定纹理并开始绘制
// 绑定纹理
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);

// 获取着色器中纹理采样器的位置
const uTextureLocation = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(uTextureLocation, 0);  // 使用纹理单元 0

灯光

摘自 MDN:在使用灯光之前,首先我们需要了解,与定义更广泛的 OpenGL 不同,WebGL 并没有继承 OpenGL 中灯光的支持。所以你只能由自己完全得控制灯光。幸运得是,这也并不是很难

期望共识

  1. 灯光 在计算机的呈现,依旧以一定的 数学模型 实现
  2. 无论是 颜色、纹理、灯光,对 WebGL 而言,仅仅是 像素点 的不同

使用 灯光 的基本步骤如下:

  1. 加载顶点数据和纹理:你需要准备顶点数据,包括顶点位置、法线等信息。在设置灯光时,法线信息是必需的,因为灯光的计算通常依赖于表面法线
const vertices = new Float32Array([
    // 位置坐标       法线坐标
    -1.0,  1.0,  0.0,  0.0,  0.0,  1.0,  // 顶点 1
    -1.0, -1.0,  0.0,  0.0,  0.0,  1.0,  // 顶点 2
     1.0,  1.0,  0.0,  0.0,  0.0,  1.0,  // 顶点 3
     1.0, -1.0,  0.0,  0.0,  0.0,  1.0   // 顶点 4
]);

// 创建并绑定缓冲区
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

2. *设置着色器及程序: *创建顶点着色器和片段着色器来计算光照效果。顶点着色器将顶点数据和法线传递给片段着色器,后者将计算光照 3. *设置光源参数: *设置光源的位置、颜色等参数,并将它们传递给着色器

  1. *设置矩阵: *需要计算并传递模型视图矩阵和投影矩阵,以便在顶点着色器中进行变换
  2. *绘制物体: *使用光照模型计算物体的最终颜色,并渲染到屏幕上。

动画

动画的本质:计算机利用人眼视觉停留的特性,在短时间内快速切换一帧帧画面,最终给人活动的视觉效果

rafRender() {
  this.deg += 0.5 // 每帧转 0.5 度

  clearBeforeRender(this.gl)
  this.draw()

  this.refId = window.requestAnimationFrame(() => this.rafRender())
}

轨道控制

轨道控制 是指用户可以通过鼠标等来操控 WebGL 绘图、动画等行为

在线体验轨道控制:http://192.168.0.199:20249/#/complex

其实当您阅读到本节,应该可以很明显的预估到:轨道控制一定不难!

为什么可以这么说的这么武断呢?

因为:轨道控制本质上仅仅是 WebGL引擎 的一种输入类型,而已!!!

接下来,我们依然效仿 three.js 的设计,自主设计出轨道控制器!

export default class GLOrbitControl {
  constructor(canvas) {
    this.#resetDragging()

    this.canvas = canvas
    this.lastMouseX = 0
    this.lastMouseY = 0

    this.#bindOrbitEvent()
  }

  // [暴露] 旋转事件
  bineRotateEvent(dX, dY) {}

  // [私有] 鼠标事件
  #bindOrbitEvent() {
    this.canvas.onmousedown = (event) => {
      this.isDragging = true
      this.lastMouseX = event.clientX
      this.lastMouseY = event.clientY
    }

    this.canvas.onmousemove = (event) => {
      if (this.isDragging) {
        const deltaX = event.clientX - this.lastMouseX
        const deltaY = event.clientY - this.lastMouseY

        // 更新鼠标位置
        this.lastMouseX = event.clientX
        this.lastMouseY = event.clientY

        // 更新视图矩阵
        this.bineRotateEvent(deltaX, deltaY)
      }
    }

    this.canvas.onmouseup = () => {
      this.#resetDragging()
    }

    this.canvas.onmouseleave = () => {
      this.#resetDragging()
    }
  }

  // [私有] 重置拖拽状态
  #resetDragging() {
    this.isDragging = false
  }
}
import GLOrbitControl from './GLOrbitControl.js'

this.glOrbitControl = new GLOrbitControl(canvas)

this.glOrbitControl.bineRotateEvent = (dX, dY) => {
  this.scene.rotateX(dX * 0.01)
  this.scene.rotateY(dY * 0.01)
}

可用性检查

本节我们将罗列多个 通过 JavaScript 检查浏览器是否支持 WebGL 的方法,如下:

使用 getContext

function isWebGLSupported() {
  try {
    const canvas = document.createElement('canvas');

    return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
  } catch (e) {
    return false;
  }
}

if (isWebGLSupported()) {
  console.log('WebGL is supported!');
} else {
  console.log('WebGL is not supported.');
}

使用现代特性检查

某些浏览器可能只支持特定版本的 WebGL,例如 WebGL 2.0。可以通过指定 webgl2 检查:

function isWebGL2Supported() {
  try {
    const canvas = document.createElement('canvas');

    return !!canvas.getContext('webgl2');
  } catch (e) {
    return false;
  }
}

if (isWebGL2Supported()) {
  console.log('WebGL 2.0 is supported!');
} else {
  console.log('WebGL 2.0 is not supported.');
}

检查具体的功能

如果需要更细致的支持判断,例如是否支持特定的扩展,可以尝试如下 :

function checkWebGLExtension(extensionName) {
  try {
    const canvas = document.createElement('canvas');
    const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');

    return gl && gl.getExtension(extensionName);
  } catch (e) {
    return false;
  }
}

// 示例:检查是否支持 OES_texture_float 扩展
if (checkWebGLExtension('OES_texture_float')) {
  console.log('OES_texture_float is supported!');
} else {
  console.log('OES_texture_float is not supported.');
}

像 three.js 一样设计

场景 Scene

import { mat4 } from 'gl-matrix'

export default class GLScene {
  constructor() {
    this.matrix = mat4.create()
  }

  lookAt(eye, center, up) {
    mat4.lookAt(this.matrix, eye, center, up)
  }

  translate(ReadonlyVec3 = [0, 0, 0]) {
    mat4.translate(this.matrix, this.matrix, ReadonlyVec3)
  }

  rotateX(rad) {
    mat4.rotateX(this.matrix, this.matrix, rad)
  }
  
  rotateY(rad) {
    mat4.rotateY(this.matrix, this.matrix, rad)
  }
  
  rotateZ(rad) {
    mat4.rotateZ(this.matrix, this.matrix, rad)
  }
}

摄像机 Camera

import { mat4 } from 'gl-matrix'

export default class GLPerspectiveCamera {
  constructor(fov, aspect, near, far) {
    this.matrix = mat4.create()

    mat4.perspective(this.matrix, fov, aspect, near, far)
  }
}

渲染器 Renderer

以 线 为例,如下设计

import GLShader from './GLShader.js'
import { clearBeforeRender } from '@/components/webgl/utils/index.js'

export default class GLRenderer {
  constructor({ canvas } = {}) {
    this.gl = canvas.getContext('webgl')
    this.glShader = new GLShader(this.gl)
  }

  // 透过摄像机渲染场景
  render() {
    clearBeforeRender(this.gl)

    this.drawLine()
  }

  // 绘制图元
  drawLine() {
    const vertices = new Float32Array([
      -0.5, 0.5, // 点 A
      0.5, -0.5, // 点 B
    ]) // 定义顶点数据

    const buffer = this.gl.createBuffer() // 创建一个新的缓冲区对象,存储顶点数据
    const posLocation = this.glShader.getLocationMap()

    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer) // 将缓冲区对象绑定到指定目标
    this.gl.bufferData(this.gl.ARRAY_BUFFER, vertices, this.gl.STATIC_DRAW)
    // 向目标缓冲对象写入顶点数据

    this.gl.vertexAttribPointer(
      posLocation.aPosition,
      2,
      this.gl.FLOAT,
      false,
      2 * Float32Array.BYTES_PER_ELEMENT,
      0
    ) // 从缓冲区对象中提取顶点属性数据,并将其传递给着色器程序中的顶点着色器
    this.gl.enableVertexAttribArray(posLocation.aPosition)
    // 启用顶点属性数据能被着色器访问

    this.gl.drawArrays(this.gl.LINES, 0, 2)
  }
}

着色器 Shader

以 线 为例,如下设计

import GLProgram from '@/components/webgl/utils/GLProgram.js'

export default class GLShader {
  constructor(gl) {
    this.gl = gl
    this.glsl = this.createGLSLSource() // 着色器源码片段
    this.glProgram = new GLProgram(this.gl, this.glsl) // 着色器程序对象
  }

  // 编写着色器 GLSL 源代码
  createGLSLSource() {
    return {
      vsCode: `
        attribute vec2 aPosition;
        varying vec4 vColor;

        void main() {
          gl_Position = vec4(aPosition, 0, 1);

          vColor = vec4(1, 0, 0, 1);
        }
      `, // 顶点着色器源码
      fsCode: `
        precision highp float;
        varying vec4 vColor;

        void main() {
          gl_FragColor = vColor;
        }
      ` // 片段着色器源码
    }
  }

  // 获取顶点属性在着色器程序中的位置映射
  getLocationMap() {
    const aPosition = this.gl.getAttribLocation(this.glProgram, 'aPosition')

    return {
      aPosition
    }
  }
}

点光源 PointLight

import { mat4 } from 'gl-matrix'

export default class GLPointLight {
  constructor() {
    this.matrix = mat4.create()
  }

  invert(sceneMatrix) {
    mat4.invert(this.matrix, sceneMatrix)
    mat4.transpose(this.matrix, this.matrix)
  }
}

程序 Program

// 着色器程序对象
export default class GLProgram {
  constructor(gl, glsl = { vsCode: '', fsCode: '' }) {
    this.gl = gl
    this.glsl = glsl

    return this.#createGLProgram()
  }

  // 初始化 WebGLProgram 程序
  #createGLProgram() {
    const program = this.gl.createProgram() // 创建 WebGL 程序对象
    const vertexShader = this.#createGLShader(
      this.gl.VERTEX_SHADER,
      this.glsl.vsCode
    )
    const fragmentShader = this.#createGLShader(
      this.gl.FRAGMENT_SHADER,
      this.glsl.fsCode
    )

    this.gl.attachShader(program, vertexShader) // 把 顶点着色器 附加到程序对象上
    this.gl.attachShader(program, fragmentShader) // 把 片段着色器 附加到程序对象上
    this.gl.linkProgram(program) // 链接程序
    this.gl.useProgram(program) // 激活程序

    if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
      return null
    }

    return program
  }

  // 创建 指定类型的 着色器
  #createGLShader(type, source) {
    const shader = this.gl.createShader(type) // 创建着色器对象

    this.gl.shaderSource(shader, source) // 将源代码发送到着色器
    this.gl.compileShader(shader) // 编译着色器源码

    if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
      this.gl.deleteShader(shader)
      return null
    } // 检查着色器是否编译成功

    return shader
  }
}