WebGL 入门

目录

本文主要介绍WebGL的一些基础知识,及用WebGL实现一个简单的颜色盘.
涉及到一些基础数学知识.

对应的代码,我开源在 github.
直接开始写吧

1. 定义 class

class ColorPicker {
  public canvas: HTMLCanvasElement
  private _radius: number
  constructor() {
    // 颜色盘是圆的,所以我们设置默认半径为 400,
    // 考虑到要设置的情况,我们要设置一下单独的 getter 和 setter 方法
    this._radius = 400
    this.canvas = document.createElement('canvas')
    this.canvas.width = 400
    this.canvas.height = 400
    this.canvas.style.width = this.canvas.style.height = 400 + 'px'
  }
  public get radius(){
    return this._radius
  }
  public set radius(val: number){
    this.canvas.width = val
    this.canvas.height = val
    this.canvas.style.width = this.canvas.style.height = val + 'px'
    this._radius = val
  }
}

定义 class 以后,准备创建 webgl 的渲染上下文, 通过 canvas 的 getContext 方法来获取渲染上下文.
因为是比较常用的属性,所以绑定在 this 上,但是是私有属性.
这样子别人引用的时候,lsp 服务会没有相关的属性,因为这是无关的属性,我们在书写代码的时候,这种属性都应该是私有的.
class 也应该和 function 一样简洁,这里说完以后,下面就不会再提了.

class ColorPicker {
  private gl: WebGLRenderingContext
  constructor(){
    const tmp = this.canvas.getContext('webgl', { antialias: true })
    if (tmp === null) {
      throw new Error('WebGL2 is not supported by your browser')
    }
    this.gl = tmp
  }
}

这是代码.

2. 创建 WebGL 程序

接下来我们创建一个工具函数,用来创建 webgl 的顶点着色器和片段着色器.然后把 glsl 的代码传进去就可以了.

2.1. vertexShader

precision mediump float;
attribute vec2 a_position;
void main() {
  gl_Position = vec4(a_position, 0, 1);
}

2.2. fragmentShader

precision mediump float;
const float PI = acos(-1.0);
const float FULL_CIRCLE_RADIANS = 2.0 * PI;
vec4 line(vec2 ndc, vec2 sPos, vec2 ePos, float w, vec3 color)
{
  vec2 dir = normalize(ePos - sPos);
  vec2 perpDir = vec2(-dir.y, dir.x);
  float d = abs(dot(ndc - sPos, perpDir));
  float l = dot(ndc - sPos, dir);
  float insidePerpendicular = smoothstep(w + 0.005, w - 0.005, d);
  float insideSegment = step(l, length(ePos - sPos)) * step(0.0, l);
  float t = insidePerpendicular * insideSegment;
  return mix(vec4(color, t), vec4(0.0), step(t, 0.0));
}
vec4 fCircle(vec2 ndc, vec3 color, float radius, vec2 pos)
{
  float t = smoothstep(radius, radius - 0.01, length(ndc - pos));
  return vec4(color, t);
}
vec4 sCircle(vec2 ndc, vec3 color, float radius, float stroke, vec2 pos)
{
  float len = length(ndc - pos);
  float r1 = radius - stroke;
  float r2 = radius + stroke;
  float t = smoothstep(r1, r1 + 0.01, len) - smoothstep(r2, r2 + 0.01, len);
  return mix(vec4(color, t), vec4(0.0), step(t, 0.0));
}
vec4 circle(vec2 ndc, vec3 fillColor, vec3 strokeColor, float strokeWidth, float radius, vec2 pos)
{
  vec4 stroke = sCircle(ndc, strokeColor, radius, strokeWidth, pos);
  return mix(fCircle(ndc, fillColor, radius, pos), stroke, stroke.a);
}
vec3 hsvToRgb(float h, float s, float v)
{
  vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
  vec3 p = abs(fract(vec3(h) + K.xyz) * 6.0 - K.w);
  return v * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), s);
}
float linear(float t, float tMin, float tMax)
{
  return tMin * (1.0 - t) + tMax * t;
}
vec3 getColorFromPoint(vec2 point, float brig)
{
  float radius = length(point);
  if (radius > 1.0)
  {
    return vec3(0.0);
  }
  float hue = atan(point.y, point.x) / FULL_CIRCLE_RADIANS; // Convert to range [0, 1]
  float saturation = radius;
  float value = 1.0;
  return hsvToRgb(hue, saturation, value);
}
void calculateColor(vec4 data[3], out vec4 result) {
  result = vec4(0.0);
  for (int i = 0; i < 3; i++) {
      vec4 c = data[i];
      result = mix(result, c, c.a);
  }
}
uniform vec2 u_resolution;
uniform vec2 u_position;
uniform float u_brightness;
uniform float u_btnSize;
uniform float u_btnStrokeWidth;
uniform float u_lineStrokeWidth;
void main() {
  vec2 uv = (gl_FragCoord.xy / u_resolution) * 2.0 - 1.0;
  vec4 data[3];
  vec3 strokeColor = vec3(1.0);
  vec3 rgb = hsvToRgb((atan(uv.y, uv.x)) / FULL_CIRCLE_RADIANS, length(uv), 1.0);
  float scale = 1.0 - u_btnSize - u_btnStrokeWidth;
  data[0] = fCircle(uv, rgb, scale, vec2(0.0, 0.0));
  data[1] = line(uv, vec2(0.0), u_position - u_position * u_btnSize, u_lineStrokeWidth, strokeColor);
  data[2] = circle(uv, getColorFromPoint(u_position, u_brightness), strokeColor, u_btnStrokeWidth, u_btnSize, u_position * scale);
  calculateColor(data, gl_FragColor);
}

2.3. OpenGL 代码解释

我们先来看看顶点着色器的代码.

首先开头的 precision mediump float; 声明了精度,我们用 mediump 就可以了,高精度的效果在这个案例里,不明显.

接着就是 attribute vec2 a_position; 声明了一个属性,这个属性是用来给顶点着色器传递坐标的,待会可以用 js 获得.
gl_Position = vec4(a_position, 0, 1); 这个代码设置了顶点的位置,是内置的属性.

然后来看片段着色器的代码,这个比较有难度了,首先需要理解一些代码运行的概念,它的 entry 函数工作方式是类似于迭代器的.
意思就是说会遍历所有的像素,然后对每个像素进行处理,处理完之后,会输出到屏幕上.所以我们要处理的东西很多.
我们首先得实现一下图层关系,为什么? 因为我们弄的颜色盘是有叠加的,不是简单的绘制一个颜色盘,得绘制手柄什么的.

来看实现的代码:

void calculateColor(vec4 data[3], out vec4 result) {
  result = vec4(0.0);
  for (int i = 0; i < 3; i++) {
      vec4 c = data[i];
      result = mix(result, c, c.a);
  }
}

要绘制的有 button, line, circle, 所以我们需要三个数据,所以我们定义了一个数组 data[3] 来存储三个数据.
然后定义一个函数 calculateColor 来计算三个数据中最亮的那个,简单的图层关系就有了.
这里的 out vec4 result 就类似于指针,待会会把 gl_FragColor 给传进去,这个变量也是内置的,给这个变量赋值就相当于在屏幕上绘制颜色了.

main 函数里拿一下对应的颜色:

  • uv 拿到当前的坐标.
  • atanlength 计算出当前的角度和半径.
  • hsvToRgb 转换成颜色.
  • fCircle 绘制一个圆.
  • line 绘制一个线.
  • circle 绘制一个圆.

这个圆就是颜色盘,这些函数都是工具函数.
接下来就是交互,需要一个工具函数来根据位置获得颜色.

vec3 getColorFromPoint(vec2 point, float brig)
{
  float radius = length(point);
  if (radius > 1.0)
  {
    return vec3(0.0);
  }
  float hue = atan(point.y, point.x) / FULL_CIRCLE_RADIANS; // Convert to range [0, 1]
  float saturation = radius;
  float value = 1.0;
  return hsvToRgb(hue, saturation, value);
}

Uniform 变量说明:

  • u_position 鼠标点击的位置,根据这个位置计算出当前的颜色.
  • u_brightness 滑动条的值,根据这个值计算出当前的颜色.
  • u_resolution 屏幕的大小,根据这个值计算出当前的位置.
  • u_btnSize 按钮的大小,根据这个值计算出当前的按钮大小.
  • u_btnStrokeWidth 按钮的边框宽度,根据这个值计算出当前的按钮边框宽度.
  • u_lineStrokeWidth 线条的宽度,根据这个值计算出当前的线条宽度.
  • u_position 按钮的位置,根据这个值计算出当前的按钮位置.

这些变量都是要通过 js 来控制的,等会会实现具体的绑定.

3. 实现 WebGLProgram

写好对应的 shader 代码以后,我们来定义一个私有方法:

private createWebGLProgram() {
  const { gl } = this
  const vertexShader = gl.createShader(gl.VERTEX_SHADER)
  if (vertexShader === null) {
    throw new Error('Create Vertex Shader return null')
  }
  gl.shaderSource(
    vertexShader,
    `刚刚的顶点着色器代码`
  )
  gl.compileShader(vertexShader)
  if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
    gl.deleteShader(vertexShader)
    throw new Error(
      `Compile shader error: ${gl.getShaderInfoLog(vertexShader)}`
    )
  }
  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
  if (fragmentShader === null) {
    throw new Error('Create Fragment Shader return null')
  }
  gl.shaderSource(
    fragmentShader,
    `刚刚的片段着色器代码`
  )
  gl.compileShader(fragmentShader)
  if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
    gl.deleteShader(fragmentShader)
    throw new Error(
      `Compile shader error: ${gl.getShaderInfoLog(fragmentShader)}`
    )
  }
  const program = gl.createProgram()
  if (program === null) {
    throw new Error('Create program return null')
  }
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    gl.deleteProgram(program)
    throw new Error(`Link program error: ${gl.getProgramInfoLog(program)}`)
  }
  gl.useProgram(program)
  return program
}

只需要写一次就行了,这个可以抽离出来,不过平时不怎么用.

3.1. WebGL 的变量和 js 交互

回到 constructor 函数里,再定义之前片段着色器里写好的代码所对应的变量,并初始化:

class ColorPicker {
  private uniformData: UniformData
  private uniformLocation: Record<keyof UniformData, WebGLUniformLocation>
  private selectorPos: Float32Array<ArrayBuffer>
  constructor() {
    this.uniformData = {
      brightness: 1.0,
      btnSize: 0.06667,
      btnStrokeWidth: 0.008,
      lineStrokeWidth: 0.004
    }
    this.uniformLocation = {
      brightness: 0,
      btnSize: 0,
      btnStrokeWidth: 0,
      lineStrokeWidth: 0
    }
    // 以防按钮拖动的时候,拖动最外面,按钮会有一半在颜色盘外面,所以我们规定减去按钮大小和描边
    this.scale =
      1.0 - this.uniformData.btnSize - this.uniformData.btnStrokeWidth
    // 记录一下鼠标的位置
    this.selectorPos = new Float32Array(2)
    // 绑定变量
    this.init()
  }
  // 动态计算当前的颜色盘的大小
  private get size() {
    return Math.max(this.canvas.width, this.canvas.height) * this.scale
  }
  // 当要访问鼠标所对应的颜色时,我们通过一点点的坐标来计算出当前的颜色,无需多言
  public get hsb() {
    const x = this.selectorPos[0]
    const y = this.selectorPos[1]
    const radius = Math.sqrt(x * x + y * y)
    let h = Math.atan2(y, x) * (180 / Math.PI)
    if (h < 0) {
      h += 360
    }
    return [h, (radius / this.scale) * 100, this.uniformData.brightness * 100]
  }
  private init() {
    const { gl, canvas, program, uniformData, uniformLocation } = this
    gl.uniform2f(
      gl.getUniformLocation(program, 'u_resolution'),
      canvas.width,
      canvas.height
    )
    // 这里是用来绑定一个 framebuffer 的
    uniformLocation.brightness = gl.getUniformLocation(program, 'u_brightness')!
    uniformLocation.btnSize = gl.getUniformLocation(program, 'u_btnSize')!
    uniformLocation.btnStrokeWidth = gl.getUniformLocation(
      program,
      'u_btnStrokeWidth'
    )!
    uniformLocation.lineStrokeWidth = gl.getUniformLocation(
      program,
      'u_lineStrokeWidth'
    )!
    // 这里调用的 uniform1f 和片段着色器那里定义的类型有关,具体的看文档
    gl.uniform1f(uniformLocation.brightness, uniformData.brightness)
    gl.uniform1f(uniformLocation.btnSize, uniformData.btnSize)
    gl.uniform1f(uniformLocation.btnStrokeWidth, uniformData.btnStrokeWidth)
    gl.uniform1f(uniformLocation.lineStrokeWidth, uniformData.lineStrokeWidth)
  }
}

我们来管理一下事件的回调和动画的播放等等…

class ColorPicker {
  private requestAnimationFrameHandle!: number // 动画的 handle
  private eventManger: [string, EventListenerOrEventListenerObject][]
  constructor() {
    this.eventManger = []
  }
  // 绘制一帧的颜色盘,后面通过 requestAnimationFrame 循环调用
  private drawOneFrame() {
    const { gl, program } = this
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0]),
      gl.STATIC_DRAW
    )
    const posAttributeLocation = gl.getAttribLocation(program, 'a_position')
    gl.enableVertexAttribArray(posAttributeLocation)
    gl.vertexAttribPointer(posAttributeLocation, 2, gl.FLOAT, false, 0, 0)
    return () => {
      gl.clearColor(0.0, 0.0, 0.0, 0.0)
      gl.clear(gl.COLOR_BUFFER_BIT)
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
    }
  }
  private addEventListener<K extends keyof HTMLElementEventMap>(
    type: K,
    listener: (this: HTMLCanvasElement, ev: HTMLElementEventMap[K]) => unknown,
    options?: boolean | AddEventListenerOptions
  ): void
  private addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions
  ) {
    this.eventManger.push([type, listener])
    this.canvas.addEventListener(type, listener, options)
  }
}

接下来就是交互,这里是比较麻烦的,需要换算鼠标坐标到 webgl 里的坐标,
通过 webgl 里的变量来计算出当前的颜色.
所以需要定义一个 toWebGlPos 和一个 isInCircle 函数来计算坐标,在 processEvent 函数里绑定事件,并更新 selectorPos 变量.

private toWebGlPos(x: number, y: number) {
  return [
    ((x - this.canvas.width * 0.5) / this.size) * 2,
    ((this.canvas.height * 0.5 - y) / this.size) * 2
  ]
}
private isInCircle(x: number, y: number) {
  const { canvas, size } = this
  const offset = (Math.max(canvas.width, canvas.height) - size) * 0.5
  const radius = size * 0.5
  const tmp = offset + radius
  return (x - tmp) ** 2 + (y - tmp) ** 2 <= radius ** 2
}
private processEvent() {
  const uPosition = this.gl.getUniformLocation(this.program, 'u_position')
  let mousedown = false
  this.addEventListener('mousedown', e => {
    const rect = this.canvas.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    if (!this.isInCircle(x, y)) {
      return
    }
    e.stopPropagation()
    const pos = this.toWebGlPos(x, y)
    this.gl.uniform2f(uPosition, pos[0], pos[1])
    mousedown = true
  })
  this.addEventListener('mousemove', e => {
    const rect = this.canvas.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    if (!this.isInCircle(x, y)) {
      this.canvas.style.cursor = 'default'
      return
    }
    e.stopPropagation()
    // 这里是为了鼠标移动的时候,鼠标样式变成十字架
    if (this.canvas.style.cursor !== 'crosshair')
      this.canvas.style.cursor = 'crosshair'
    if (mousedown) {
      const pos = this.toWebGlPos(x, y)
      this.gl.uniform2f(uPosition, pos[0], pos[1])
      this.selectorPos.set(pos)
    }
  })
  this.addEventListener('mouseup', e => {
    e.stopPropagation()
    mousedown = false
  })
}

4. 最后

我们来暴露一下公共方法,供外部调用:

public setUniformData(key: keyof UniformData, value: number) {
  this.uniformData[key] = value
  const tmp = this.uniformLocation[key]
  if (tmp) {
    this.gl.uniform1f(tmp, value)
  }
}
public install(parent: HTMLElement = document.body) {
  parent.appendChild(this.canvas)
  const draw = this.drawOneFrame()
  const drawLoop = () => {
    draw()
    this.requestAnimationFrameHandle = requestAnimationFrame(drawLoop)
  }
  drawLoop()
  this.processEvent()
}
public uninstall() {
  cancelAnimationFrame(this.requestAnimationFrameHandle)
  this.eventManger.forEach(event => {
    this.canvas.removeEventListener(event[0], event[1])
  })
}

以上就是所有的内容了,希望能帮到你.

日期: 2025-5-27

作者: 赵大牛