WebGL 基础 - 点线面等绘制基础

495 阅读4分钟

坐标系

WebGL 的坐标系不同于普通的 canvas

首先我们来看一下 canvas2D 坐标系:

image.png

它的原点在左上角,X 轴正方向朝右,Y 轴正方向朝下,canvas 的长宽是按照像素大小进行绘制,1就代表1px。

下面再来看一下 WebGL 的坐标系:

image.png

WebGL 的原点是在整个画布的中心,X 轴的正方向朝右,Y 轴正方向朝上,Z 轴正方向朝屏幕外侧,WebGL 的大小表示也不一样,1 代表画布的宽、高或深度的一半。所以如果我们想要用像素来表示我们所画出来的元素的大小,那么就需要进行转换。

点的绘制比较简单,其核心 API 就是 gl.drawArrays(gl.POINTS, n, m);

直接上代码,initShaders 代码见上一篇,下同。

<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders } from '../utils';

onMounted(() => {
  const canvas = document.querySelector('canvas');
  if (!canvas) {
    return;
  }

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const gl = canvas.getContext('webgl') as WebGLRenderingContext;

  const vertex = `
    void main() {
      gl_PointSize = 100.0;
      gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
    } 
  `;

  const fragment = `
    void main() {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }    
  `;

  initShaders(gl, vertex, fragment);

  // 声明底色
  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.POINTS, 0, 1);
});
</script>

<template>
  <canvas></canvas>
</template>

多点绘制

其实和单点绘制区别不大,只是多了一个多点数据构建和传值的过程。

<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders } from '../utils';

onMounted(() => {
  const canvas = document.querySelector('canvas');
  if (!canvas) {
    return;
  }

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const gl = canvas.getContext('webgl') as WebGLRenderingContext;

  const vertex = `
    attribute vec4 a_Position;
    void main() {
      gl_PointSize = 100.0;
      gl_Position = a_Position;
    } 
  `;

  const fragment = `
    void main() {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }    
  `;

  const [res, program] = initShaders(gl, vertex, fragment);

  if (!res) {
    return
  }

  // 创建三角形顶点
  const points = new Float32Array([
    -0.5, -0.5,
    0, 0.5,
    0.5, -0.5,
  ]);

  // 将定义好的数据写入 WebGL 的缓冲区
  const bufferId = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
  gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

  // 将缓冲区的数据读取到 GPU
  // 获取顶点着色器中的position变量的地址
  const vPosition = gl.getAttribLocation(program, 'a_Position');
  // 给变量设置长度和类型
  gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
  // 激活这个变量
  gl.enableVertexAttribArray(vPosition);

  // 声明底色
  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.POINTS, 0, 3);
});
</script>
  
<template>
  <canvas></canvas>
</template>

线

线的绘制分为三种类型,分别对应三个API。

直接上代码:

<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders } from '../utils';

onMounted(() => {
  const canvas = document.querySelector('canvas');
  if (!canvas) {
    return;
  }

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const gl = canvas.getContext('webgl') as WebGLRenderingContext;

  const vertex = `
    attribute vec4 a_Position;
    void main() {
      gl_Position = a_Position;
    } 
  `;

  const fragment = `
    void main() {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }    
  `;

  const [res, program] = initShaders(gl, vertex, fragment);

  if (!res) {
    return
  }

  // 创建矩形顶点
  const points = new Float32Array([
    -0.5, -0.5,
    -0.5, 0.5,
    0.5, -0.5,
    0.5, 0.5,
  ]);

  // 将定义好的数据写入 WebGL 的缓冲区
  const bufferId = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
  gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

  // 将缓冲区的数据读取到 GPU
  // 获取顶点着色器中的position变量的地址
  const vPosition = gl.getAttribLocation(program, 'a_Position');
  // 给变量设置长度和类型
  gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
  // 激活这个变量
  gl.enableVertexAttribArray(vPosition);

  // 声明底色
  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.LINES, 0, 4);
  // gl.drawArrays(gl.LINE_STRIP, 0, 4);
  // gl.drawArrays(gl.LINE_LOOP, 0, 4);
});
</script>
  
<template>
  <canvas></canvas>
</template>

gl.LINES

gl.drawArrays(gl.LINES, n, m) 根据点绘制线,点与点两两相连。

效果如下图所示:

image.png

gl.LINE_STRIP

gl.drawArrays(gl.LINE_STRIP, n, m) 根据点绘制线,点与点依次相连。

效果如下图所示:

image.png

gl.LINE_LOOP

gl.drawArrays(gl.LINE_LOOP, n, m) 根据点绘制线,点与点依次相连,最后一个点与第一个点会形成闭合线段。

效果如下图所示:

image.png

WebGL 中对面的绘制其实就是画三角形,各种各样的三角形组合起来就变成了面甚至是有了3D的效果。

面的绘制方式比点和线要复杂一点,所以需要结合图文讲解。

gl.TRIANGLES

这是最简单的一种绘制方式,就是每三个点代表一个三角形,绘制 n 个三角形就需要 3n 个点。

绘制代码与上面绘制线的代码类似,修改一下点与绘制API即可:

// ......
const points = new Float32Array([
  -0.5, -0.5,
  -0.5, 0.5,
  0.5, -0.5,
    
  0.6, 0.5,
  -0.4, 0.5,
  0.6, -0.5,
]);

// ......
gl.drawArrays(gl.TRIANGLES, 0, 6);

效果如下图所示:

image.png

gl.TRIANGLE_STRIP

绘制三角带,三角带点位的绘制是有顺序的,还分正反。

正面:面向我们的面逆时针绘制。

反面:面向我们的面顺时针绘制。

三角带绘制的顺序如下:

image.png

  • 第一个三角形:v0 -> v1 -> v2
  • 第偶数个三角形:v2 -> v1 -> v3,以上一个三角形的第二条边的反方向 + 下一个点作为基础绘制
  • 第奇数个三角形:v2 -> v3 -> v4,以上一个三角形的第三条边的反方向 + 下一个点作为基础绘制

关键代码如下:

// ......
const points = new Float32Array([
  -0.5, -0.5,
  -0.5, 0.5,
  0.5, -0.5, 
  0.5, 0.5,
]);

// ......
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

效果如下图所示:

image.png

gl.TRIANGLE_FAN

绘制三角带,以上一个三角形的第三条边的反方向 + 下一个点为基础绘制。

image.png

关键代码如下:

// ......
const points = new Float32Array([
  -0.5, -0.5,
  -0.5, 0.5,
  0.5, -0.5, 
  0.4, -0.8,
]);

// ......
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);

效果如下图所示:

image.png

同步与异步绘制

这里的同步与异步指的是调用 API 绘制的顺序。

同步绘制指的就是多次绘制在一段同步代码中执行,这样的绘制结果只关乎于你调用了多少次绘制 API,每次绘制的结果都会被保留在画布上,呈现出来。

异步绘制指的多次绘制,在异步代码中分批执行了,这样去执行后一次会覆盖前一次绘制的结果,所以只会显示最后一次的内容。

比如:我们在屏幕中点击一次就在屏幕上多画一个点出来,每次点击的绘制显然是异步绘制的情况,所以我们在绘制的时候就需要对数据进行一些处理。专门用一个 points 数组来存储我们的点位数据,点一次就往数组里面加一个数据,绘制的时候遍历数组,没存在一个点就执行一次绘制 API,这个过程就是一个同步绘制的过程。

代码如下:

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { initShaders } from '../utils';

interface Color {
  r: number;
  g: number;
  b: number;
  a: number;
}

interface Point {
  x: number;
  y: number;
  size: number;
  color: Color;
}

const points = ref<Array<Point>>([{
  x: 0,
  y: 0,
  size: 10.0,
  color: {
    r: 1.0,
    g: 0.0,
    b: 0.0,
    a: 1.0,
  }
}]);

onMounted(() => {
  const canvas = document.querySelector('canvas');
  if (!canvas) {
    return;
  }

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const gl = canvas.getContext('webgl') as WebGLRenderingContext;
  // 开启片元的颜色合成功能
  gl.enable(gl.BLEND);
  // 设置片元的合成方式
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA);

  // 定义 attribute 变量,变量名 a_Position,类型 vec4
  const vertex = `
    attribute vec4 a_Position;
    attribute float a_PointSize;
    void main() {
      gl_PointSize = a_PointSize;
      gl_Position = a_Position;
      
    } 
  `;

  // 定义 uniform 变量,变量名 u_FragColor vec4
  // precision mediump float 用于定义片元着色器的精度
  const fragment = `
    precision mediump float;
    uniform vec4 u_FragColor;
    void main() {
      float dist = distance(gl_PointCoord, vec2(0.5, 0.5));
      if (dist < 0.5) {
        gl_FragColor = u_FragColor;
      } else {
        discard;
      }
    }    
  `;

  const [res, program] = initShaders(gl, vertex, fragment);

  if (!res) {
    return
  }

  // 获取着色器中的 attribute 变量
  const a_Position = gl.getAttribLocation(program, 'a_Position');
  const a_PointSize = gl.getAttribLocation(program, 'a_PointSize');

  // 获取 uniform 变量
  const u_FragColor = gl.getUniformLocation(program, 'u_FragColor');

  // 声明底色
  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.drawArrays(gl.POINTS, 0, 1);

  const render = () => {
    gl.clear(gl.COLOR_BUFFER_BIT);
    points.value.forEach(point => {
      gl.vertexAttrib2f(a_Position, point.x, point.y);
      gl.vertexAttrib1f(a_PointSize, point.size);

      const { r, g, b, a } = point.color;
      const arr = new Float32Array([r, g, b, a]);
      gl.uniform4fv(u_FragColor, arr);
      gl.drawArrays(gl.POINTS, 0, 1);
    });
  };

  canvas.addEventListener('click', (event: MouseEvent) => {
    const { clientX, clientY } = event;

    const { left, top, height, width } = canvas.getBoundingClientRect();

    const [cssX, cssY] = [clientX - left, clientY - top];

    // WebGL 的坐标原点在画布中心,canvas 里面则是左上角
    const [halfWidth, halfHeight] = [width / 2, height / 2];

    // WebGL 里面的一个单位代表画布的宽或高的一半,canvas 里面则是代表一个像素
    const [xBaseCenter, yBaseCenter] = [
      cssX - halfWidth,
      -(cssY - halfHeight), // WebGL Y 轴方向与 canvas 也是相反的
    ];
    
    // 解决坐标基底差异
    const [x, y] = [xBaseCenter / halfWidth, yBaseCenter / halfHeight];

    points.value.push({
      x,
      y,
      size: Math.random() * 30,
      color: {
        r: Math.random(),
        g: Math.random(),
        b: Math.random(),
        a: Math.random(),
      }
    });
    render();
  })
});
</script>

<template>
  <canvas></canvas>
</template>

纹理

WebGL 对图片素材是有严格要求的,图片的宽度和高度必须是 2 的 N 次幂,比如 16 x 16,32 x 32,64 x 64 等(否则无法直接使用)。实际上,不是这个尺寸的图片也能进行贴图,但是这样会使得贴图过程更复杂,从而影响性能,所以我们在提供图片素材的时候最好参照这个规范。

另外,图片坐标系与纹理坐标系是有差异的,图片坐标系其实就是 canvas2D 坐标系,纹理坐标系就是 WebGL 的坐标系。

下面是一些关键代码:

export function loadTexture(
  gl: WebGLRenderingContext,
  src: string,
  attribute: WebGLUniformLocation,
  callback?: () => void,
) {
  const img = new Image();
  img.crossOrigin = 'anonymous';
  img.onload = function() {
    gl.activeTexture(gl.TEXTURE0);
    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
    gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.uniform1i(attribute, 0);
    if (callback) {
      callback();
    }
  };
  img.src = src;
}
<script setup lang="ts">
import { onMounted } from 'vue';
import { initShaders, loadTexture } from '../utils';

onMounted(() => {
  const canvas = document.querySelector('canvas');
  if (!canvas) {
    return;
  }

  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  const gl = canvas.getContext('webgl') as WebGLRenderingContext;

  const vertex = `
    precision mediump float;
    attribute vec2 a_Position;
    // 接收JavaScript传递过来的顶点 uv 坐标。
    attribute vec2 a_Uv;
    // 将接收的uv坐标传递给片元着色器
    varying vec2 v_Uv;
    void main() {
      gl_Position = vec4(a_Position, 0, 1);
      v_Uv = a_Uv;
    } 
  `;

  const fragment = `
    precision mediump float;
    // 接收顶点着色器传递过来的 uv 值。
	varying vec2 v_Uv;
	// 接收 JavaScript 传递过来的纹理
	uniform sampler2D u_Texture;
    void main() {
      // 提取纹理对应uv坐标上的颜色,赋值给当前片元(像素)。
  	  gl_FragColor = texture2D(u_Texture, vec2(v_Uv.x, v_Uv.y));
    }    
  `;

  const [res, program] = initShaders(gl, vertex, fragment);

  if (!res) {
    return
  }

  // 创建矩形顶点
  const points = new Float32Array([
    // 前两位是 WebGL 坐标,后面两位是 canvas 坐标
    0, 0, 0, 1, // V0
    0, 1, 0, 0, // V1
    1, 1, 1, 0, // V2
    0, 0, 0, 1, // V0
    1, 1, 1, 0, // V2
    1, 0, 1, 1, // V3
  ]);

  // 将定义好的数据写入 WebGL 的缓冲区
  const bufferId = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
  gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

  const aPosition = gl.getAttribLocation(program, 'a_Position');
  gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 16, 0);
  gl.enableVertexAttribArray(aPosition);

  const aUv = gl.getAttribLocation(program, 'a_Uv');
  gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 16, 8);
  gl.enableVertexAttribArray(aUv);

  const uTexture = gl.getUniformLocation(program, 'u_Texture') as WebGLUniformLocation;
  const render = () => {
    gl.clearColor(0, 0, 0, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(gl.TRIANGLES, 0, points.length / 4);
  };
  loadTexture(gl, './top.png', uTexture, render);
});
</script>
  
<template>
  <canvas></canvas>
</template>

效果如下图所示:

image.png

简化 JS 与 GLSL 交互的库

gl-render

OGL