webgl详解part4:实战!手把手教你在Canvas里画出第一个三角形

206 阅读8分钟

webgl详解part4:实战!手把手教你在Canvas里画出第一个三角形

骚话鬼才又来分享WebGL干货啦!前面咱们把WebGL的API、底层逻辑都唠了个遍,今天直接上手实操,带你用WebGL在HTML的canvas里画出人生第一个三角形。别眨眼,跟着骚话王一步一步来,保证你也能在前端3D江湖里"亮剑出鞘"!

如果觉得有用,记得点赞收藏,骚话鬼才后续还有更多实战技巧等你来拿!


目标:用WebGL在Canvas中渲染一个三角形

咱们的终极目标很简单:

  • 用HTML写一个canvas
  • 用JavaScript调用WebGL API
  • 创建着色器、程序对象、缓冲区
  • 把三角形画到屏幕上

别怕,骚话王会把每一步都拆开讲,代码和原理全都安排得明明白白。


第一步:准备HTML和Canvas

先来一段最基础的HTML,画布就是咱们的"江湖擂台":

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>WebGL三角形实战</title>
  <style>
    body { background: #222; color: #fff; }
    canvas { display: block; margin: 40px auto; background: #333; }
  </style>
</head>
<body>
  <canvas id="webgl-canvas" width="400" height="400"></canvas>
  <script src="triangle.js"></script>
</body>
</html>

你可以直接把这段HTML保存为index.html,canvas的id和尺寸可以随意调整。


第二步:编写JavaScript,召唤WebGL上下文

新建一个triangle.js,先获取WebGL上下文:

const canvas = document.getElementById('webgl-canvas');
const gl = canvas.getContext('webgl');
if (!gl) {
  alert('你的浏览器不支持WebGL,换个浏览器试试吧!');
}

骚话王小贴士:

  • getContext('webgl')是WebGL的"入场券",拿不到就啥都别谈。
  • 有些老浏览器可能只支持experimental-webgl,可以兼容性处理下。

第三步:写好着色器源码

WebGL的"魔法阵"——着色器,分为顶点着色器和片元着色器。直接用字符串写在JS里:

// 顶点着色器源码
const vertexShaderSource = `
  attribute vec2 position;
  void main() {
    gl_Position = vec4(position, 0.0, 1.0);
    gl_PointSize = 10.0; // 虽然这里没用到点,但演示下
  }
`;

// 片元着色器源码
const fragmentShaderSource = `
  precision mediump float;
  void main() {
    gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0); // 橙色
  }
`;

着色器源码用反引号包裹,多行写更清晰。


第四步:创建、编译着色器对象

封装一个创建着色器的函数,省得每次都写一大堆:

function createShader(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const info = gl.getShaderInfoLog(shader);
    gl.deleteShader(shader);
    throw new Error('着色器编译失败:' + info);
  }
  return shader;
}

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

骚话王小贴士:

  • 编译失败一定要看infoLog,GLSL语法错一个字母都不行。

第五步:创建程序对象,链接着色器

把顶点和片元着色器"组队"成一个完整的程序:

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)) {
    const info = gl.getProgramInfoLog(program);
    gl.deleteProgram(program);
    throw new Error('程序对象链接失败:' + info);
  }
  return program;
}

const program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program);

第六步:准备三角形顶点数据,上传到GPU

三角形有3个顶点,每个顶点2个坐标(x, y),范围-1到1:

const vertices = new Float32Array([
  0.0,  0.5,  // 顶点1(上)
 -0.5, -0.5,  // 顶点2(左下)
  0.5, -0.5   // 顶点3(右下)
]);

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

你可以把缓冲区想象成"快递仓库",顶点数据都得先存进去。


第七步:配置attribute,把数据送进着色器

先查到attribute的位置,再告诉WebGL怎么读数据:

const positionLoc = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
  • 2:每个顶点2个分量(x, y)
  • gl.FLOAT:数据类型
  • false:不归一化
  • 0:步长为0,紧密排列
  • 0:偏移为0,从头开始

骚话王小贴士:

  • attribute名要和着色器里一模一样,大小写敏感。
  • 步长和偏移单位是字节,不是元素个数。

第八步:清屏,绘制三角形!

一切准备就绪,点火开工:

gl.clearColor(0.1, 0.1, 0.1, 1.0); // 背景色
// 清除颜色缓冲
 gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制三角形
 gl.drawArrays(gl.TRIANGLES, 0, 3);

你可以把drawArrays想象成"发号施令",让GPU正式开工。


完整代码一览

把上面所有代码合起来,就是一个完整的三角形渲染Demo:

// 获取canvas元素,相当于找到"画布"
const canvas = document.getElementById('webgl-canvas');
// 获取WebGL上下文,后续所有WebGL操作都靠它
const gl = canvas.getContext('webgl');
if (!gl) {
  alert('你的浏览器不支持WebGL,换个浏览器试试吧!');
}

// 顶点着色器源码,负责处理每个顶点的位置
const vertexShaderSource = `
  attribute vec2 position; // 顶点输入变量,二维坐标
  void main() {
    gl_Position = vec4(position, 0.0, 1.0); // 组装成四维向量,送进裁剪空间
  }
`;
// 片元着色器源码,负责决定每个像素的颜色
const fragmentShaderSource = `
  precision mediump float; // 指定浮点精度
  void main() {
    gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0); // 输出橙色
  }
`;

// 封装创建着色器的函数,type可以是顶点或片元
function createShader(gl, type, source) {
  const shader = gl.createShader(type); // 创建着色器对象
  gl.shaderSource(shader, source);      // 注入GLSL源码
  gl.compileShader(shader);             // 编译着色器
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { // 检查编译结果
    const info = gl.getShaderInfoLog(shader);
    gl.deleteShader(shader);
    throw new Error('着色器编译失败:' + info);
  }
  return 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)) { // 检查链接结果
    const info = gl.getProgramInfoLog(program);
    gl.deleteProgram(program);
    throw new Error('程序对象链接失败:' + info);
  }
  return program; // 返回可用的程序对象
}

// 创建并编译着色器
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource); // 顶点着色器
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); // 片元着色器
// 创建并链接程序对象
const program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program); // 激活程序对象,后续绘制都靠它

// 定义三角形的三个顶点,每个顶点2个分量(x, y),范围-1到1
const vertices = new Float32Array([
  0.0,  0.5,   // 顶点1(上)
 -0.5, -0.5,   // 顶点2(左下)
  0.5, -0.5    // 顶点3(右下)
]);
// 创建缓冲区对象,用于存储顶点数据
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); // 绑定为当前操作的缓冲区
// 把顶点数据搬进GPU仓库
 gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

// 获取attribute变量在程序中的位置(编号)
const positionLoc = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(positionLoc); // 启用attribute变量
// 告诉WebGL如何从缓冲区读取数据送进position
// 参数依次为:位置、每组数据个数、类型、是否归一化、步长、偏移
// 这里每个顶点2个float,紧密排列,无偏移
 gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);

// 设置清屏颜色(背景色)为深灰色
 gl.clearColor(0.1, 0.1, 0.1, 1.0);
// 清除颜色缓冲区,准备开画
 gl.clear(gl.COLOR_BUFFER_BIT);
// 发号施令,绘制三角形!参数:图元类型、起始索引、顶点数量
 gl.drawArrays(gl.TRIANGLES, 0, 3);

  • 着色器编译失败? 99%是GLSL语法写错,大小写、分号、变量名都要对。
  • 画布没显示三角形? 检查canvas尺寸、attribute配置、drawArrays参数。
  • 颜色不对? 检查gl_FragColor赋值。
  • 浏览器不支持? 换新版Chrome/Edge/Firefox,WebGL基本都支持。
  • 调试建议:多用console.loggl.getError()排查问题。

补充:WebGL 1.0 和 WebGL 2.0 有啥区别?API升级全解析

骚话鬼才又来科普啦!很多小伙伴刚学会WebGL 1.0,结果一看网上还有WebGL 2.0,顿时一脸懵圈:这俩到底啥关系?API有啥不一样?是不是2.0更牛逼?今天就来给你掰扯掰扯,WebGL 1.0和2.0的区别,尤其是API上的升级!


1. WebGL 1.0:前端3D的"启蒙老师"

WebGL 1.0 基于 OpenGL ES 2.0,算是前端3D的"开山鼻祖"。它让你用JavaScript就能调动GPU,玩转3D世界。

  • 优点:兼容性好,几乎所有主流浏览器都支持。
  • 缺点:有些高级特性用不了,比如多重采样、3D纹理、实例化绘制等。

2. WebGL 2.0:前端3D的"进阶武器"

WebGL 2.0 则是基于 OpenGL ES 3.0,功能更强大,API更丰富,性能更高。

  • 优点:支持更多现代图形特性,效率更高,画面更炫酷。
  • 缺点:部分老设备/浏览器不支持(但新设备基本都OK)。

3. API上的主要区别

(1)着色器语言升级
  • WebGL 1.0 只支持 GLSL ES 1.0
  • WebGL 2.0 支持 GLSL ES 3.0,语法更强大,支持更多内置函数和数据类型
(2)顶点属性和缓冲区
  • WebGL 2.0 支持 顶点数组对象(VAO),用 gl.createVertexArray() 管理attribute配置,切换更高效
  • 支持 实例化绘制,如 gl.drawArraysInstanced(),一行代码画一堆对象,粒子系统、草地森林so easy
(3)纹理和采样
  • WebGL 2.0 支持 3D纹理多重采样纹理浮点纹理,画面更细腻
  • 支持 gl.texImage3D()gl.sampler 等新API
(4)渲染管线增强
  • 支持 多重渲染目标(MRT),一次渲染输出多个颜色缓冲,后处理、延迟渲染必备
  • 支持 gl.drawBuffers()
(5)Uniform和UBO
  • WebGL 2.0 支持 Uniform Buffer Object(UBO),批量传递uniform,效率飞起
  • 支持 gl.getUniformBlockIndex()gl.uniformBlockBinding()
(6)更多内置常量和API
  • WebGL 2.0 新增了很多常量,比如 gl.RGBA32Fgl.SAMPLER_3D
  • 新增API如 gl.fenceSync()gl.invalidateFramebuffer()

4. 代码举例:VAO的用法

WebGL 1.0 没有VAO,每次切换attribute都要重新配置:

// WebGL 1.0
// 每次都要enable/disable/vertexAttribPointer

WebGL 2.0 可以这样:

// WebGL 2.0
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// 配置attribute...
gl.bindVertexArray(null); // 切换只需bind

5. 如何判断当前环境支持WebGL 2.0?

const gl2 = canvas.getContext('webgl2');
if (gl2) {
  console.log('支持WebGL 2.0,骚操作可以安排上!');
} else {
  console.log('只支持WebGL 1.0,基础功能也够用!');
}

示例封装的简易shader类(可按照需求自行更改)

export interface WebGLShaderProgramOptions {
  autoDeleteShader?: boolean // link成功后自动删除shader,默认true
}

export class WebGLShaderProgram {
  private gl: WebGLRenderingContext | WebGL2RenderingContext
  private vertexSource: string
  private fragmentSource: string
  private vertexShader: WebGLShader | null = null
  private fragmentShader: WebGLShader | null = null
  private program: WebGLProgram | null = null
  private options: WebGLShaderProgramOptions

  constructor(
    gl: WebGLRenderingContext | WebGL2RenderingContext,
    vertexSource: string,
    fragmentSource: string,
    options: WebGLShaderProgramOptions = {}
  ) {
    this.gl = gl
    this.vertexSource = vertexSource
    this.fragmentSource = fragmentSource
    this.options = { autoDeleteShader: true, ...options }
    try {
      this.init()
    } catch (e) {
      this.destroy()
      throw e
    }
  }

  /**
   * 判断当前上下文是否为WebGL2
   */
  isWebGL2(): boolean {
    return typeof WebGL2RenderingContext !== 'undefined' && this.gl instanceof WebGL2RenderingContext
  }

  /**
   * 编译shader源码,类型区分顶点/片元,失败时抛出详细错误并清理资源
   */
  private createShader(type: number, source: string): WebGLShader {
    const shader = this.gl.createShader(type)
    if (!shader) throw new Error((type === this.gl.VERTEX_SHADER ? '顶点' : '片元') + 'Shader无法创建')
    this.gl.shaderSource(shader, source)
    this.gl.compileShader(shader)
    if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
      const info = this.gl.getShaderInfoLog(shader)
      this.gl.deleteShader(shader)
      throw new Error((type === this.gl.VERTEX_SHADER ? '顶点' : '片元') + 'Shader编译失败: ' + info)
    }
    return shader
  }

  /**
   * 初始化并链接program,任一步骤失败都自动清理资源
   */
  private init() {
    try {
      this.vertexShader = this.createShader(this.gl.VERTEX_SHADER, this.vertexSource)
      this.fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, this.fragmentSource)
      this.program = this.gl.createProgram()
      if (!this.program) throw new Error('Program无法创建')
      this.gl.attachShader(this.program, this.vertexShader)
      this.gl.attachShader(this.program, this.fragmentShader)
      this.gl.linkProgram(this.program)
      if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
        const info = this.gl.getProgramInfoLog(this.program)
        throw new Error('Program链接失败: ' + info)
      }
      // link成功后自动删除shader
      if (this.options.autoDeleteShader) {
        if (this.vertexShader) {
          this.gl.deleteShader(this.vertexShader)
          this.vertexShader = null
        }
        if (this.fragmentShader) {
          this.gl.deleteShader(this.fragmentShader)
          this.fragmentShader = null
        }
      }
    } catch (e) {
      this.destroy()
      throw e
    }
  }

  /**
   * 激活当前program
   */
  use() {
    if (this.program) {
      this.gl.useProgram(this.program)
    }
  }

  /**
   * 获取底层WebGLProgram对象
   */
  getProgram() {
    return this.program
  }

  /**
   * 释放所有WebGL资源
   */
  destroy() {
    if (this.vertexShader) {
      this.gl.deleteShader(this.vertexShader)
      this.vertexShader = null
    }
    if (this.fragmentShader) {
      this.gl.deleteShader(this.fragmentShader)
      this.fragmentShader = null
    }
    if (this.program) {
      this.gl.deleteProgram(this.program)
      this.program = null
    }
  }
}

如果觉得有用,记得点赞收藏,骚话王后续还会带来更多WebGL骚操作,咱们下篇再见!