WebGPU是一门神奇的技术,在浏览器支持率0%,标准还没有定稿的情况下,就已经被Three.js和Babylon.js等主流3D和游戏框架支持了。而且被Tensorflow.js用来加速手机端的深度学习,比起WebGL能带来20~30倍的显著提升。
在主流框架中 WebGPU 的例子

1、在Three.js中使用WebGPU

使用Three.js的封装,我们可以直接生成WebGPU的调用。
我们照猫画虎引入WebGPU相关的库:
import
 * 
as
 THREE 
from'three'
;

import
 * 
as
 Nodes 
from'three-nodes/Nodes.js'
;

import
 { add, mul } 
from'three-nodes/ShaderNode.js'
;


import
 WebGPU 
from'./jsm/capabilities/WebGPU.js'
;

import
 WebGPURenderer 
from'./jsm/renderers/webgpu/WebGPURenderer.js'
;

...

剩下就跟普通的WebGL代码写起来差不多:
asyncfunctioninit() 
{


if
 ( WebGPU.isAvailable() === 
false
 ) {

document
.body.appendChild( WebGPU.getErrorMessage() );

thrownewError
'No WebGPU support'
 );

    }


const
 container = 
document
.createElement( 
'div'
 );

document
.body.appendChild( container );


    camera = 
new
 THREE.PerspectiveCamera( 
45
window
.innerWidth / 
window
.innerHeight, 
1
4000
 );

    camera.position.set( 
0
200
1200
 );


    scene = 
new
 THREE.Scene();

...

只不过渲染器使用WebGPURenderer:
    renderer = 
new
 WebGPURenderer();

    renderer.setPixelRatio( 
window
.devicePixelRatio );

    renderer.setSize( 
window
.innerWidth, 
window
.innerHeight );

    container.appendChild( renderer.domElement );

...

如果封装的不能满足需求了,我们可以使用WGSL语言进行扩展:
    material = 
new
 Nodes.MeshBasicNodeMaterial();

    material.colorNode = desaturateWGSLNode.call( { 
color
new
 Nodes.TextureNode( texture ) } );

    materials.push( material );


const
 getWGSLTextureSample = 
new
 Nodes.FunctionNode( 
`

     fn getWGSLTextureSample( tex: texture_2d<f32>, tex_sampler: sampler, uv:vec2<f32> ) -> vec4<f32> {

      return textureSample( tex, tex_sampler, uv ) * vec4<f32>( 0.0, 1.0, 0.0, 1.0 );

     }

    `
 );


const
 textureNode = 
new
 Nodes.TextureNode( texture );


    material = 
new
 Nodes.MeshBasicNodeMaterial();

    material.colorNode = getWGSLTextureSample.call( { 
tex
: textureNode, 
tex_sampler
: textureNode, 
uv
new
 Nodes.UVNode() } );

    materials.push( material );

WGSL是WebGPU进行GPU指令编程的语言。类似于OpenGL的GLSL, Direct3D的HLSL。
我们来看一个完整的例子,显示一个跳舞的小人,也不过100多行代码:
<!DOCTYPE html>
<htmllang="en">
<head>
<title>
three.js - WebGPU - Skinning
</title>
<metacharset="utf-8">
<metaname="viewport"content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<linktype="text/css"rel="stylesheet"href="main.css">
<metahttp-equiv="origin-trial"content="AoS1pSJwCV3KRe73TO0YgJkK9FZ/qhmvKeafztp0ofiE8uoGrnKzfxGVKKICvoBfL8dgE0zpkp2g/oEJNS0fDgkAAABeeyJvcmlnaW4iOiJodHRwczovL3RocmVlanMub3JnOjQ0MyIsImZlYXR1cmUiOiJXZWJHUFUiLCJleHBpcnkiOjE2NTI4MzE5OTksImlzU3ViZG9tYWluIjp0cnVlfQ==">
</head>
<body>
<divid="info">
<ahref="https://threejs.org"target="_blank"rel="noopener">
three.js
</a>
 WebGPU - Skinning

</div>
<scriptasyncsrc="https://unpkg.com/[email protected]/dist/es-module-shims.js"></script>
<scripttype="importmap">
   {

"imports"
: {

"three"
"../build/three.module.js"
,

"three-nodes/"
"./jsm/nodes/"
    }

   }

</script>

<scripttype="module">
import
 * 
as
 THREE 
from'three'
;

import
 * 
as
 Nodes 
from'three-nodes/Nodes.js'
;

import
 { FBXLoader } 
from'./jsm/loaders/FBXLoader.js'
;

import
 WebGPU 
from'./jsm/capabilities/WebGPU.js'
;

import
 WebGPURenderer 
from'./jsm/renderers/webgpu/WebGPURenderer.js'
;

import
 LightsNode 
from'three-nodes/lights/LightsNode.js'
;


let
 camera, scene, renderer;

let
 mixer, clock;

   init().then( animate ).catch( error );


asyncfunctioninit() 
{

if
 ( WebGPU.isAvailable() === 
false
 ) {

document
.body.appendChild( WebGPU.getErrorMessage() );

thrownewError
'No WebGPU support'
 );

    }

    camera = 
new
 THREE.PerspectiveCamera( 
50
window
.innerWidth / 
window
.innerHeight, 
1
1000
 );

    camera.position.set( 
100
200
300
 );

    scene = 
new
 THREE.Scene();

    camera.lookAt( 
0
100
0
 );

    clock = 
new
 THREE.Clock();


// 光照
const
 light = 
new
 THREE.PointLight( 
0xffffff
 );

    camera.add( light );

    scene.add( camera );

const
 lightNode = 
new
 LightsNode().fromLights( [ light ] );

const
 loader = 
new
 FBXLoader();

    loader.load( 
'models/fbx/Samba Dancing.fbx'
function ( object 
{

     mixer = 
new
 THREE.AnimationMixer( object );

const
 action = mixer.clipAction( object.animations[ 
0
 ] );

     action.play();

     object.traverse( 
function ( child 
{

if
 ( child.isMesh ) {

       child.material = 
new
 Nodes.MeshStandardNodeMaterial();

       child.material.lightNode = lightNode;

      }

     } );

     scene.add( object );

    } );


// 渲染
    renderer = 
new
 WebGPURenderer();

    renderer.setPixelRatio( 
window
.devicePixelRatio );

    renderer.setSize( 
window
.innerWidth, 
window
.innerHeight );

document
.body.appendChild( renderer.domElement );

window
.addEventListener( 
'resize'
, onWindowResize );

return
 renderer.init();

   }


functiononWindowResize() 
{

    camera.aspect = 
window
.innerWidth / 
window
.innerHeight;

    camera.updateProjectionMatrix();

    renderer.setSize( 
window
.innerWidth, 
window
.innerHeight );

   }


functionanimate() 
{

    requestAnimationFrame( animate );

const
 delta = clock.getDelta();

if
 ( mixer ) mixer.update( delta );

    renderer.render( scene, camera );

   }


functionerror( error 
{

console
.error( error );

   }

</script>
</body>
</html>

2、在Babylon.js中使用WebGPU

Babylon.js的封装与Three.js大同小异,我们来看个PlayGround的效果:
不同之处在于处理WebGPU的支持情况时,Babylon.js并不判断整体上支不支持WebGPU,而是只看具体功能。
比如上面的例子,只判断是不是支持计算着色器。
const
 supportCS = engine.getCaps().supportComputeShaders;

不过目前在macOS上,只有WebGPU支持计算着色器。
如果我们把环境切换成WebGL2,就变成下面这样了:
顺便说一句,Babylon.js判断WebGL2和WebGL时也是同样的逻辑,有高就用高。
如果对于着色器不熟悉,Babylon.js提供了练习Vertex Shader和Pixel Shader的环境:https://cyos.babylonjs.com/ , 带语法高亮和预览。
针对需要通过写手机应用的场景,Babylon.js提供了与React Native结合的能力:

3、用WebGPU进行深度学习加速

除了3D界面和游戏,深度学习的推理器也是GPU的重度用户。所以Tensorflow.js也在还落不了地的时候就支持了WebGPU。实在是计算着色器太重要了。
写出来的加速代码就像下面一样,很多算子的实现最终是由WGSL代码来实现的,最终会转换成GPU的指令。
  getUserCode(): string {

const
 rank = 
this
.xShape.length;

const
 type = getCoordsDataType(rank);

const
 start = 
this
.xShape.map(
(_, i) =>`uniforms.pad${i}[0]`
).join(
','
);

const
 end = 
this
.xShape

                    .map(

(_, i) =>`uniforms.pad${i}[0] + uniforms.xShape
${

                            rank > 
1
 ? 
`[${i}]`
 : 
''
}
`
)

                    .join(
','
);

const
 startValue = rank > 
1
 ? 
`${type}(${start})`
 : 
`${start}`
;

const
 endValue = rank > 
1
 ? 
`${type}(${end})`
 : 
`${end}`
;


const
 leftPadCondition = rank > 
1
 ? 
`any(outC < start)`
 : 
`outC < start`
;

const
 rightPadCondition = rank > 
1
 ? 
`any(outC >= end)`
 : 
`outC >= end`
;


const
 unpackedCoords = rank > 
1
 ?

        [
'coords[0]'
'coords[1]'
'coords[2]'
'coords[3]'
].slice(
0
, rank) :

'coords'
;


const
 userCode = 
`

${getMainHeaderAndGlobalIndexString()}
        if (index < uniforms.size) {

          let start = 
${startValue}
;

          let end = 
${endValue}
;

          let outC = getCoordsFromIndex(index);

          if (
${leftPadCondition}
 || 
${rightPadCondition}
) {

            setOutputAtIndex(index, uniforms.constantValue);

          } else {

            let coords = outC - start;

            setOutputAtIndex(index, getX(
${unpackedCoords}
));

          }

        }

      }

    `
;

return
 userCode;

  }

无框架手写WebGPU代码
通过框架,我们可以迅速地跟上技术的前沿。但是,框架的封装也容易让我们迷失对于技术本质的把握。
现在我们来看看如何手写WebGPU代码。

1、从Canvas说起

不管是WebGL还是WebGPU,都是对于Canvas的扩展。做为HTML 5的重要新增功能,大家对于2D的Canvas应该都不陌生。
比如我们要画一个三角形,就可以调用lineTo API来实现:
<!DOCTYPE html>
<htmllang="en">
<head>
<metacharset="UTF-8">
<title>
Canvas
</title>
</head>
<body>
<canvasid="webcanvas"width="200"height="200"style="background-color: #eee"></canvas>
<script>
const
 canvas=
document
.getElementById(
'webcanvas'
);

const
 ctx=canvas.getContext(
'2d'
);


        ctx.beginPath();

        ctx.moveTo(
75
,
50
);

        ctx.lineTo(
100
,
75
);

        ctx.lineTo(
100
,
25
);

        ctx.fill();

</script>
</body>
画出来的结果如下:
我们要修改画出来的图的颜色怎么办?

ctx有fillStyle属性,支持CSS的颜色字符串。
比如我们设成红色,可以这么写:
ctx.fillStyle = 
'red'
;

也可以这么写:
ctx.fillStyle = 
'#F00'
;

还可以这么写:
ctx.fillStyle = 
'rgb(255,0,0,1)'
;

2、从2D到3D

从2D Canvas到3D WebGL的最大跨越,就是从调用API,到完全不同于JavaScript的新语言GLSL的出场。
第一步的步子我们迈得小一点,不画三角形了,只画一个点。
<!DOCTYPE html>
<htmllang="en">

<head>
<metacharset="UTF-8">
<title>
Test OpenGL for a point
</title>
</head>

<body>
<canvasid="webgl"width="500"height="500"style="background-color: blue"></canvas>
<script>
const
 canvas = 
document
.getElementById(
'webgl'
);

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


const
 program = gl.createProgram();


const
 vertexShaderSource = 
`

           void main(){

              gl_PointSize=sqrt(20.0);

              gl_Position =vec4(0.0,0.0,0.0,1.0);

           }`
;


const
 vertexShader = gl.createShader(gl.VERTEX_SHADER);

        gl.shaderSource(vertexShader, vertexShaderSource);

        gl.compileShader(vertexShader);

        gl.attachShader(program, vertexShader);


const
 fragShaderSource = 
`

          void main(){

            gl_FragColor = vec4(1.0,0.0,0.0,1.0);

          }

        `
;


const
 fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

        gl.shaderSource(fragmentShader, fragShaderSource);

        gl.compileShader(fragmentShader);

        gl.attachShader(program, fragmentShader);


        gl.linkProgram(program);

        gl.useProgram(program);


        gl.drawArrays(gl.POINTS, 
0
1
);


</script>

</body>

</html>
getContext时将2d换成webgl。

我们可以加一行
console.log(gl)
来看下gl是什么东西:
我们可以看到,它是一个WebGLRenderingContext对象。

顺便说一句,之前我们拿到的2D的Context是CanvasRenderingContext2D。
下面就引入了两段程序中的程序,第一段叫做顶点着色器,用于顶点的坐标信息。第二段叫做片元着色器,用于配置如何进行一些属性的操作,在本例中我们做一个最基本的操作,改颜色。
我们先看顶点着色器的代码:
void main(){

gl_PointSize=sqrt(20.0);

gl_Position =vec4(0.0,0.0,0.0,1.0);

}

像其他语言一样,glsl中的代码也需要一个入口函数。
gl_PointSize是一个系统变量,用于存储点的大小。我特意给大小加个了sqrt函数,给大家展示glsl的库函数。
gl_Position用于存储起点的位置。vec4是由4个元素构成的向量。
GLSL的数据类型很丰富,包括标量、向量、数组、矩阵、结构体和采样器等。
标量有布尔型bool, 有符号整数int, 无符号整数uint和浮点数float 4种类型。
类型的使用方式跟C语言一样,比如我们用float来定义浮点变量。
float pointSize = sqrt(20.0);

gl_PointSize=pointSize;

GLSL没有double这样表示双精度的类型。在顶点着色器中是没有精度设置的。

但是在片元着色器中有精度的设置,需要指定低精度lowp, 中精度mediump和高精度highp. 一般采用中精度:
void main(){

mediump vec4 pointColor;

pointColor.r = 1.0;

pointColor.a = 1.0;

gl_FragColor = pointColor;

}

GLSL因为是基于C语言设计的,不支持泛型,所以每种向量同时有4种子类型的。
以四元组vec4为例,有4种类型:
  • vec4: 浮点型向量
  • ivec4: 整数型向量
  • uvec4: 无符号整数向量
  • bvec4: 布尔型向量。
另外还有vec2, vec3各有4种子类型,以此类推。
在GLSL里面,四元向量最常用的用途有两种,在顶点着色器里充当坐标,和在片元着色器里充当颜色。
当vec4作为坐标使用时,我们可以用x,y,z,w属性来对应4个维度。
我们来看个例子:
vec4 pos;

pos.x = 0.0;

pos.y = 0.0;

pos.z = 0.0;

pos.w = 1.0;

gl_Position = pos;

同样,我们在片元着色器里面表示红色的时候只用指令r和a两个属性,g,b让它们默认是0:
void main(){

mediump vec4 pointColor;

pointColor.r = 1.0;

pointColor.a = 1.0;

gl_FragColor = pointColor;

}

有了顶点着色器和片元着色器的GLSL代码之后,我们将其进行编程,并attach到program上面。
最后再link和use这个program,就可以调用drawArrays来进行绘制了。

3、更现代的GPU编程方法

跨越了从 Canvas API到GLSL的鸿沟了之后,最后到WebGPU这一步相对就容易一些了。
我们要熟悉的是以Vulkan为代表的更现代的GPU的编程方法。
渲染管线不再是唯一,我们可以使用更通用的计算管线了。也不再有顶点着色器和片元着色器那么严格的限制。
另外最重要的一点是,为了提升GPU执行效率,WebGPU不再是像WebGL一样基本每一步都要由CPU来控制,我们使用commandEncoder将所有GPU指令打包在一起,一次性执行。
我们先看一下完整代码有个印象:
<!DOCTYPE html>
<htmllang="en">

<head>
<metacharset="UTF-8">
<title>
Test WebGPU
</title>
</head>

<body>
<canvasid="webgpu"width="500"height="500"style="background-color: blue"></canvas>
<script>
asyncfunctiontestGPU() 
{

const
 canvas = 
document
.getElementById(
'webgpu'
);

const
 gpuContext = canvas.getContext(
'webgpu'
);


const
 adapter = 
await
 navigator.gpu.requestAdapter();

const
 device = 
await
 adapter.requestDevice();


            presentationFormat = gpuContext.getPreferredFormat(adapter);


            gpuContext.configure({

                device,

format
: presentationFormat

            });


const
 triangleVertWGSL = 
`

            @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);

            }

            `
;


const
 redFragWGSL = 
`

            @stage(fragment)

                fn main() -> @location(0) vec4<f32> {

                return vec4<f32>(1.0, 0.0, 0.0, 1.0);

            }

            `


const
 commandEncoder = device.createCommandEncoder();

const
 textureView = gpuContext.getCurrentTexture().createView();


const
 pipeline = device.createRenderPipeline({

vertex
: {

module
: device.createShaderModule({

code
: triangleVertWGSL,

                    }),

entryPoint
'main'
,

                },

fragment
: {

module
: device.createShaderModule({

code
: redFragWGSL,

                    }),

entryPoint
'main'
,

targets
: [

                        {

format
: presentationFormat,

                        },

                    ],

                },

primitive
: {

topology
'triangle-list'
,

                },

            });


const
 renderPassDescriptor = {

colorAttachments
: [

                    {

view
: textureView,

loadValue
: { 
r
1.0
g
1.0
b
1.0
a
1.0
 }, 

storeOp
'store'
,

                    },

                ],

            };

const
 passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);

console
.log(passEncoder);

            passEncoder.setPipeline(pipeline);

            passEncoder.draw(
3
1
0
0
);

            passEncoder.end();


            device.queue.submit([commandEncoder.finish()]);

        }


        testGPU();


</script>

</body>

</html>
因为浏览器还没有支持,所以我们需要像Chrome Canary这样的支持最新技术的浏览器。而且还要打开支持的开关,比如在Chrome Canary里是enable-unsafe-webgpu.
三角形画出来的结果如下:
现在的Context从WebGL的WebGLRenderingContext变成了GPUCanvasContext。
WGSL语言的语法更像Rust,vec4这样的容器可以用泛型的写法绑定类型:
@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);

}

对比下Rust的代码看看像不像:
fnfib2
(n: 
i32
) -> 
i64
 {

if
 n <= 
2
 {

return1i64
    } 
else
 {

return
 fib2(n - 
1
) + fib2(n - 
2
)

    }

}

WGSL是为了规避知识产权问题发明的新语言,本质上它和GLSL,HLSL等语言一样,都可以编译成Vulkan的SPIR-V二进制格式:

.
Vulkan不限制使用什么样的语言,既可以使用GLSL, HLSL,也可以使用Open CL或者是Open CL的高级封装SYCL。
转换成SPIR-V格式之后,可以转成iOS上的Metal Shading Language,也可以转成Windows Direct 12上用的DXIL。
WebGPU没有这么自由,发明了一门新语言WGSL,不过其思想都是基于SPIR-V的。
在WebGPU和WGSL还未定版,资料还比较缺乏的情况下,我们可以先学习Vulkan相关的知识,然后迁移到WebGPU上来。本质上是同样的东西,只是封装略有不同。
我们之前学习的GLSL的知识同样用得上,而且在这种类Rust风格中可以写得更爽一些。
比如同样是给片元用的颜色值,在保留了vec4可以继续使用r,g,b,a分量的好处之外,因为指定了f32的精度,就不需要mediump了。而且,类型可以自动推断,我们直接给个var就好了:
@stage(fragment)

fn main() -> @location(0) vec4<f32> {

var triColor = vec4<f32>(0.0,0.0,0.0,0.0);

triColor.r = 1.0;

triColor.a = 1.0;

return triColor;

}

有了作为功能核心的WGSL,剩下的工作主要就是组装了。
我们把指令打包在 CommandEncoder中,然后通过beginRenderPass来创建一个渲染Pass,再给这个Pass设置一个渲染的流水线,添加相应的draw操作,最后提交到GPU设备的队列中,就大功告成了。
小结
相对于基于OpenGL ES 2.0的WebGL 1.0,WebGPU更接近于Vulkan这样更能发挥GPU能力的新API,可以更有效地发挥出新的GPU的能力。就像渲染上Three.js和Babylon.js给我们展示的那样和计算上Tensorflow.js的飞跃一样。
虽然浏览器还不支持,但是不成熟的主要是封装,底层的Vulkan和Metal技术已经非常成熟,并且广泛被客户端所使用了。
WebGPU这个能力暴露给H5和小程序之后,将给元宇宙等热门应用插上性能倍增的翅膀。结合WebXR等支持率更成问题的新技术一起,成为未来几年前端的主要工具。

关注「Alibaba F2E」微信公众号把握阿里巴巴前端新动向
继续阅读
阅读原文