计算机图形学笔记:从 WebGL 到 WebGPU

目前 WebGPU 的标准还没有完全确定下来,需要下载开发者版本的 Chrome Canary 才能开启 WebGPU。(目前正式版中 Chrome 96 / 97 其实已经支持 WebGPU了,但并不是完全支持,98 (据说)会正式支持 WebGPU)。

WebGL 与 WebGPU

WebGL 的基础是 OpenGL。OpenGL 的初始版本可以追溯到 1992 年,整个 OpenGL 的设计是基于状态机模型。状态驱动的 OpenGL 难以利用今天 GPU 并行的特点。对于今天的多核计算的设备,使用 OpenGL 非常难以发挥机器的全部计算能力。WebGL 基于 OpenGL,这些 OpenGL 的问题也继承到了 WebGL了。

除此之外,OpenGL 也被各大厂商抛弃。微软的 Windows 有 Direct X,Apple 则提出了 Metal,并且直接不支持新版本的 OpenGL 4,科纳斯则提出了“次世代 OpenGL“ 的 Vulkan。这些新的图形编程语言的特点都是为了适应多核并行的高性能计算。

当然,复杂度也要比 OpenGL 更高。如果学习过 DirectX 12(支持多线程)和 DirectX 11,应该很清楚,DirectX 12 比 11 复杂多了。

需要注意的是,WebGPU 和 WebGL 并没有关系,它不需要显式地依赖 OpenGL ES。从某种意义上,wgsl 也是一门新的语言,然后经由浏览器翻译成更加底层的实现,至于底层是 Vulkan、Metal 或者是 Direct X 等等,则已经不是重点了。

由于 WebGPU 的入口是浏览器,它的标准会尽可能的大众化,也就是不能对设备要求过高,否则这个标准的通用程度也会是一个很大的问题。不可能要求每个需要使用 WebGPU 的用户的 GPU 都是 RTX 3090。可以理解,这也导致标准的复杂性会相应增加。

WebGPU 基本概念

  • GPUAdapter :WebGPU 中将物理的 GPU 硬件视为 GPUAdapter
  • GPUDevice :管理资源(它可能有自己处理单元的显存)
  • GPUQueue :执行 GPU 命令的队列
  • GPUBuffer (缓冲区)和 GPUTexture (纹理):在 GPU 内存(显存)中烘焙的物理资源
  • GPUCommandBufferGPURenderBundle 是用户记录命令的容器
  • GPUShaderModule 包含着色器代码。
  • GPUSamplerGPUBindGroup,配置 GPU 使用其它物理资源的方式

安全性

和 Web 一样,会保证是当前页面数据。由于通过浏览器翻译成底层的 GPU 语言,在翻译阶段会严格限定指令集并进行验证,避免出现未定义行为。

还有其他的针对安全问题的考虑,暂时不讨论

图形学基础概念

如果没有清楚一些基础概念,无论是学 WebGL 还是 WebGPU 都是无法深入下去的。(由于篇幅有限,这里只是简单概述一下,后续需要具体用到的概念再展开)

图形系统

计算机图形系统也是一个计算机系统,因此它肯定也包含了一个通用计算机的所有部件。图形系统包括以下 6 个部分:

  • 输入设备(鼠标、键盘、手绘板…)
  • 中央处理单元(CPU)
  • 图形处理单元(GPU)
  • 存储器(包括常说的内存、显存)
  • 帧缓冲区(比如前后缓冲区)
  • 输出设备(显示器、投影等等)

这是一个通用的模型,从手机到计算机都可以用这个模型。

像素和帧缓存

现代所有图形系统都是基于光栅的。输出设备看到的图像是由图形系统产生的图形元素组成的序列:

  • 图形元素也叫做像素(pixel)
  • 像素阵列称为光栅(raster)
  • 帧缓冲区(framebuffer)中的像素称为分辨率(resolution),决定了图像中可以分辨出多少细节
    • 帧缓冲区的深度(depth)或者精度(precision)表示的是像素所用的比特数,深度为 1 的帧缓冲区只能有 2 1 2^1 21 = 2 种颜色,而深度为 8 的帧缓冲区可以表示 256( 2 8 2^8 28) 种颜色。
    • 全彩(full-color)显示中,每个像素 24 比特(现在基本上都有 32 比特)。这样系统可以逼真地表示大多数图像,也被称为真彩色(true-color)系统,或者 RGB 系统
  • 高动态范围(High Dynamic Range,HDR)给每一个颜色分配了 12 或者更多的比特位。
    • 现在,帧缓冲区已经支持用浮点数表示颜色值,可以更好的支持 HDR

在简单的图形系统中,帧缓冲区只存储屏幕上要显示的像素的颜色值。但在目前的图形系统中,帧缓冲区存储的信息其实非常多,比如还有生成 3D 图像所需要的深度信息等等。

输出设备

计算显示设备的历史可以追溯到阴极射线管(Cathode Ray Tube,CRT)。二十多年前那种带着长长尾巴的电视机就是基于这种原理。

计算机图形学笔记:从 WebGL 到 WebGPU_第1张图片

CRT 因为电子轰击涂在管子上的磷光物质而发光。电子束的方向由两对偏转板控制。计算机的输出由数模转换器转换成 x 偏转板和 y 偏转板上的电压值。当强度足够大的电子束轰击到磷光物质上,CRT 屏幕就会发光。

如果控制电子束偏转的电压以恒定的速率改变,那么电子束就会扫过一条直线轨迹。这样的设备称为随机扫描(random-scan)显示器。

磷光物质被电子束激发以后,典型的 CRT 的发光只能持续很短的时间,一般是几毫秒。为了让人看到稳定的、不闪烁的图像,电子束必须以足够高的速率重复扫描相同的路径,这就是刷新(refresh),刷新的速率叫做刷新率。在早期的系统中,刷新的速率由电源的(交流电)频率决定,例如美国是 60 Hz,而其他大多数地方是 50 Hz。当然,目前显示器已经远远不止 60 Hz 了,例如 iPad Pro 可以 120 Hz,一些专业的游戏笔记本可以达到 144 Hz。

  • 逐行扫描:像素按照刷新率一行一行地显示
  • 隔行扫描:奇数行和偶数行交替刷新
    • 60 Hz 的隔行扫描,每秒钟屏幕被完整刷新只有 30 次

后面,CRT 就被平板显示技术取代了。平板显示器也是基于光栅原理,虽然平板的具体物理实现可能不同,比如是发光二极管(LED)、液晶显示器(LCD)、等离子显示屏等,但都使用了二维栅格来寻址每个单独的发光元件。

  • LED:由传递到栅格上的电信号控制开启和关闭;
  • LCD:电场改变液晶的排列方式,可以控制是否阻挡光线通过;
  • 等离子显示器:通过激发玻璃板的中的气体,获得能量的气体变成发光的等离子体;

3D 显示则利用交替刷新周期在左眼和右眼看到图像进行切换显示。观众需要佩戴满足一定刷新周期的特制眼镜。3D 电影放映机则会产生具有不同偏振方向的两个图像(通常称为左右眼),观众佩戴偏振光眼镜,这样每个眼睛只看到两个图像的一个,经过大脑的视觉合成,从而形成 3D 效果。

光学成像模型

因为这里不打算讲物理学(虽然后面如果深入了解光和材质的话,像光度学的概念还是要懂的)。这里一笔带过,现实世界中,我们作为观察者看到的图像是由于环境中光源投射到物体上,然后物体反射到我们的眼睛(当然,还有光源直射你的眼睛、包括物体本身的漫反射等等)。

物体(对象)的材质不同,对光的反射和折射也会不同(目前计算机图形学都是基于几何光学,至于波动光学(诸如衍射、偏振等)是不用于计算机图形学的)。所以材质代表了对光处理模型,例如是完全反射回去(理想镜子),还是会在内部进行不断的散射(蜡烛的次表面散射模型)。

三维绘图的 API

目前计算机三维模型使用的虚拟照相机模型,按照这个模型,API 就需要有下面四种类型的函数:

  • 对象
  • 观察者(也就是视点、相机)
  • 光源
  • 材质

流水线体系结构

图形绘制系统的体系结果经过多次反复,但无论如何演变,流水线结构的重要性依然不变。其他的图形绘制方法,包括光线跟踪、辐射度方法和光子映射都不能获得实时的行为。

而现在,流水线体系结构中,顶点处理模块和片元处理模块是可以编程的。

  • 顶点着色器可以在顶点经过流水线的时候修改每个顶点的位置或者颜色。这样就可以实现许多光线-材质模型或者创建新型的投影。
  • 片元着色器可以用许多新的方式来使用纹理,也可以实现基于每个片元的光照而不是每个顶点的光照。

WebGL 的三角形

初始化着色器

在 WebGL 中,首先需要创建顶点着色器和片段着色器,并且绑定和编译对应的着色器程序。在这之后,需要将这两个着色器程序链接成为一个着色器。

function initShader(gl, vertexShader, fragShader) {
    // 创建顶点着色器
    const v_shader = gl.createShader(gl.VERTEX_SHADER);
    // 绑定顶点着色器代码
    gl.shaderSource(v_shader, vertexShader);
    // 编译顶点着色器
    gl.compileShader(v_shader);
    // 创建片段着色器
    const f_shader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(f_shader, fragShader);
    gl.compileShader(f_shader);
    // 创建着色器程序
    const shaderProgram = gl.createProgram();
    gl.attachShader(shaderProgram, v_shader);
    gl.attachShader(shaderProgram, f_shader);
    gl.linkProgram(shaderProgram);
    gl.useProgram(shaderProgram);
    // WebGL 没有类似 getProgram 的接口,所以绑定到 program 上
    gl.program = shaderProgram
}

绘制一个点

要绘制一个点,首先区分流水线上的不同节点,点的位置和大小属于顶点着色器的内容,而顶点的颜色属于片段着色器。和 C 系语言类似,由 main 函数开始作为程序的入口,需要注意的是,返回值不是 int,而是 void

顶点着色器,点的位置最后的 1.0 主要目的是齐次化,这样才可以更好地运用在矩阵运算中,避免由于颜色的 4 个维度(rgba)而无法进行矩阵运算。

// 顶点着色器代码 
const v_shader = `
    void main() {
        gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
        gl_PointSize = 10.0;
    }`;
// 片段着色器代码
const f_shader = `
    void main() {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }`;

获取 HTML 标签,清空画布就都是比较容易理解的操作:

    // DOM 节点与 WebGL 上下文
    const canvas = document.getElementById("hello-point");
    const gl = canvas.getContext("webgl");
    if (!gl) {
        console.log("Failed to get the rendering context for WebGL.")
        return;
    }
    // 初始化着色器
    initShader(gl, v_shader, f_shader);
    // 清空  颜色
    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT)
    // 绘制点
    gl.drawArrays(gl.POINTS, 0, 1);

计算机图形学笔记:从 WebGL 到 WebGPU_第2张图片

绘制多个点

对于多个点的组成的图形,例如三角形、矩形和立方体。我们肯定更希望一次性把图形的的顶点全部传入到顶点着色器,而不是用循环一次一个顶点的传入。WebGL 提供了缓冲区对象(buffer object),它可以一次性地向着色器传入多个顶点的数据。缓冲区对象是 WebGL 系统中的一块内存区域,可以一次性地向缓冲区对象中填充大量的顶点数据。

使用缓冲区对象向顶点着色器传入多个顶点的数据,需要遵循以下五个步骤(处理其他对象,例如纹理、帧缓冲区对象也类似):

  1. 创建缓冲区对象 gl.createBuffer()
  2. 绑定缓冲区对象 gl.bindBuffer()
  3. 将数据写入缓冲区对象 gl.bufferData()
  4. 将缓冲区对象分配给一个 attribute 变量 gl.vertexAttribPointer()
  5. 开启 attribute 变量 gl.enableVertexAttribArray()
function initVertexBuffers(gl) {
    const vertices = Float32Array.of(...[0.0, 0.5, -0.5, -0.5, 0.5, -0.5]);
    const n = 3;

    // 1. 创建缓冲区对象
    const vertexBuffer = gl.createBuffer();
    if (!vertexBuffer) {
        console.log("Failed to create the buffer object.");
        return -1;
    }

    // 2. 绑定目标
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
    // 3. 写入缓冲区
    gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
    // 4. 创建分配 attribute 变量
    const a_Position = gl.getAttribLocation(gl.program, "a_Position");
    if (a_Position < 0) {
        console.log("Failed to get the storage location of a_Position");
        return -1;
    }
    // 需要指明数据的格式,这里坐标是 2 个 Float,并且没有归一化
    gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
    // 5. 开启 attribute 变量
    gl.enableVertexAttribArray(a_Position);
    return n;
}

着色器程序中片段着色器提供颜色信息,这里不需要修改。由于我们传入的是多个点,需要将顶点着色器中的顶点改为 attribute vec4

const v_shader = `
    attribute vec4 a_Position;
    void main() {
        gl_Position = a_Position;
        gl_PointSize = 10.0;
    }`;

绘制点的数量也从 1 修改为 n:

    // 初始化着色器
    initShader(gl, v_shader, f_shader);

    const n = initVertexBuffers(gl);
    if (n < 0) {
        console.log("Failed to get the positions of the vertices");
        return;
    }

    // 清空  颜色
    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT)
    // 绘制点
    gl.drawArrays(gl.POINTS, 0, n);

计算机图形学笔记:从 WebGL 到 WebGPU_第3张图片

三角形

只要在上面的代码上稍微改改,就能得到一个三角形:

  1. gl_PointSize = 10.0; 去掉,因为只有绘制点的时候,点的大小才是有用的;
  2. gl.drawArrays 的第一个参数从 gl.POINTS 修改为三角形 gl.TRIANGLES
gl.drawArrays(gl.TRIANGLES, 0, n);

计算机图形学笔记:从 WebGL 到 WebGPU_第4张图片

WebGPU 的三角形

WebGPU 的流水线体系结构和 WebGL 没什么差别,应该说整个实时渲染的渲染大体步骤都是一样的。顶点着色器到顶点组装(例如组装成三角形),经过剪裁和光栅化后进入片段着色器进行着色再到最终显示。WebGPU 从一开始就考虑的是并发(并行)的计算,所以异步过程贯穿始终。当然,今天的前端开始也是如此。

wsgl 的语法挺像 Rust 的…代码高亮我都选的是 Rust…但却是比 GL 的语法晦涩一点,也可能不止一点。

初始化

由于整个过程是异步的,所以需要使用 async 声明函数:

(async () => {
// ... 下面的代码写在这里 ...
})();

WebGPU 的具体实现是没有限制的,Windows 可能是使用 Direct X 12,而 OSX 则可能是 Metal。为了屏蔽这种差异,引入了适配器层,由适配器提供对应的底层设备:

// 适配器
const adapter = await navigator.gpu.requestAdapter();
// 通过适配器获取具体设备
const device = await adapter.requestDevice();

接下来获取画布和上下文就和 WebGL 没有太大的差别了:

const canvas = document.getElementById("webgpu-canvas");
const context = canvas.getContext("webgpu");

由于具体实现是由适配器管理的,绘制所需要的纹理格式同样应该通过适配器获取。可以通过 getPreferredFormat 获取对应的的纹理格式。

const presentationFormat = context.getPreferredFormat(adapter);
context.configure({
    device,
    format: presentationFormat
});

顶点缓冲区和颜色缓冲区

和 WebGL 一样,也需要声明顶点缓冲区和颜色缓冲区,需要注意的是,CPU 和 GPU 是异构的。可以使用 createBuffer 映射显存,为了让 GPU 能再次访问这块显存,它必须取消映射 umap()

// 映射显存
const positionBuffer = device.createBuffer({
    size: 24,
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true
});
// 取消映射
positionBuffer.unmap();

const colorBuffer = device.createBuffer({
    size: 12,
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true
});    
colorBuffer.unmap();

顶点着色器

[[stage(vertex)]]
fn main([[builtin(vertex_index)]] VertexIndex : u32)
     -> [[builtin(position)]] vec4<f32> {
  var pos = array<vec2<f32>, 3>(
      vec2<f32>(0.0, 0.5),
      vec2<f32>(-0.5, -0.5),
      vec2<f32>(0.5, -0.5));

  return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
}

stage 的中文是阶段,[[stage(vertex)]] 显然说明了这是一个顶点着色阶段。builtin 则指明是内置类型:

  • vertex_index : 顶点索引,类似数组下标,值的类型为 u32
  • position : [输出] 当前顶点的输出位置,使用的是齐次坐标。所以从 pos 中获取到二维坐标后,再补上两个维度(zw )的值,组成一个 vec4 四维的坐标。

片元着色器

片元着色 RGBA,代表不透明的纯红色。

[[stage(fragment)]]
fn main() -> [[location(0)]] vec4<f32> {
  return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

location(0) 和 WebGL 中 (location = 0) 是一个意思,都是指定该属性在顶点缓冲区中的位置。

创建渲染管线

指定渲染管线和提供必要的材料,我们之前已经说过了,GPU 流水线中,可以定制代码的主要就是就是顶点着色器和片元着色器。createShaderModule 绑定 shader 源码,entryPoint 指定入口函数。buffers 则描述了对象的布局。

const pipeline = device.createRenderPipeline({
    vertex: {
        module: device.createShaderModule({
            code: vs
        }),
        entryPoint: "main",
        buffers: [
            {
                // 步进值(单位:byte),32位浮点数占 4 bytes,平面坐标x,y 所以是 => 8
                arrayStride: 8,
                attributes: [{
                    shaderLocation: 0,
                    format: "float32x2",
                    offset: 0
                }]
            },
            {
                // 8位 无符号整数 = 1byte * 4 = 4bytes
                arrayStride: 4,
                attributes: [{
                    shaderLocation: 1,
                    // UNORM 无符号整数
                    format: "unorm8x4",
                    offset: 0
                }]
            }
        ]
    },
    fragment: {
        module: device.createShaderModule({
            code: fs
        }),
        entryPoint: "main",
        targets: [{
            format: presentationFormat
        }]
    },
    primitive: {
        // 图元
        topology: "triangle-list"
    }
});

提交渲染命令

// 创建指令编码器
const commandEncoder = device.createCommandEncoder();
// 视图
const textureView = context.getCurrentTexture().createView();
// 启动渲染通道(WebGPU 还提供了计算通道)
const renderPass = commandEncoder.beginRenderPass({
    colorAttachments: [{
        view: textureView,
        loadValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 } // 背景颜色
    }]
});
// 设置渲染管线
renderPass.setPipeline(pipeline);

renderPass.setVertexBuffer(0, positionBuffer);
renderPass.setVertexBuffer(1, colorBuffer);
    
renderPass.draw(3, 1, 0, 0);
// 结束管道编码
renderPass.endPass();
// 提交到队列,commandEncoder 调用 finish 完成编码操作,并返回一个指令缓存
device.queue.submit([commandEncoder.finish()]);

计算机图形学笔记:从 WebGL 到 WebGPU_第5张图片

你可能感兴趣的:(WebGPU,WebGPU)