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拿到当前的坐标.atan和length计算出当前的角度和半径.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])
})
}
以上就是所有的内容了,希望能帮到你.