贴图:将图片的内容贴在物体的表面上,对应片元的位置,输出原图片对应的像素信息。
贴图的坐标系:与NDC坐标一样,WebGPU或者现代图形API也规范了一套贴图的坐标系,用来描述贴图的大小和位置信息。
以普通2D贴图为例:
图片的左上角对应原点(0,0),向右和向下为正方向,一般用字母U和V来做表示。为了适应不同的贴图大小,在这个UV坐标系中,图片的最大的宽和高都被归一化用1来做表示,即(1,1)坐标对应的是图片的右下角。
这样我们就可以通过描述一个面顶点的UV坐标,来定位图片在一个面中的位置和大小。
除此之外,我们还可以通过UV进行放大缩小原始图片。
WebGPU或者现代图形API对此有一个Address的配置,也就是如何处理贴图周围的像素点。默认是clamp-to-edge(复用纹理边缘的像素信息)。除此之外还有repeat(超出1.0的部分将重复循环原始图案,即平铺)、mirror-repeat(超出1.0的部分会左右或上下翻转之后再做平铺,即镜像重复)。
实际操作中可以单独设置水平方向或者垂直方向不同的address方案,来组合显示不同的效果。
真实场景中还可能因为3D空间的透视变化,导致贴图内容的大小变化也不一样,近大远小对贴图同样适用。
UV坐标是理论0~1的连续空间,但真实的图像是一个个离散的像素点。如果原图和贴图的分辨率大小不一样,该如何去匹配具体的像素点内容?
Sampling (重)采样是数字信号处理的一个基本概念,任何数据包括音频视频图像等等,只要输出的大小或者频率跟原始的数据不一致,都要对原始数据进行采样或重采样。
放大或缩小在GPU内部中就是应用不同的filter。图形学API底层内置了很多相关算法,不需要我们自己去实现采样的过程。
目前WebGPU可以直接选择两种采样方法来处理像素点的变化。
缩放后的每一个像素点都会根据UV差值形成一个新的坐标。
如果相邻的两个像素点距离一样,主流图形学算法会四舍五入优先选择像素点大的坐标,会选择x坐标y坐标更大的像素点,(正方向夹角的方向)。
同理缩小算法的过程也一样。首先找到UV坐标对应的新的坐标点,然后做距离对比。
边缘的颜色会比较明显,但误差也比较大,会造成比较明显的像素点的信息缺失。好处是策略简单,GPU的计算量很小,是一种性能优先的缩放采样方法。WebGPU也是默认使用这种策略进行采样缩放的。
首先找到原始坐标找到原始图像中对应的坐标点。与临近策略不同的是,它选取的不是最近的一个点,而是选择最近的四个像素点的颜色来做加权平均,即根据4个点距离大小的占比不同来混合计算最后的颜色。
线性计算的过程更复杂一些,计算量也更大一些,但最后的效果相对比,更加符合原始图像,像素点的过度比较均匀,但也一定程度上弱化了边缘的界限。
实际操作过程中,如果原始图像分辨率足够,一般无论使用哪种方法进行缩小的操作,肉眼都很难看出区别,但如果是放大的话效果就比较明显了。临近采样会在边缘呈现非常明显的锯齿感(马赛克效果),而线性采样边缘过渡比较平滑,但也在一定程度上造成了边缘模糊的情况。
两者的最终效果不一定谁好谁劣,只是适用的内容不同。一般地,临近采样更适合边缘分割明显,颜色区域少的图像。而颜色丰富,过度较多的图,用线性采样的效果更好。
社区中有很多采样优化算法, 但考虑到GPU的运行效率问题,主流图形API中只内置了这两种方法。其他的优化采样算法,我们可以在shader中自己进行实现。
一般来说,比起复杂的算法优化,提高原始图像的分辨率,实际的收益效果可能会更明显一些。
https://github.com/Orillusion/orillusion-webgpu-samples
/src/imageTexture.ts
/src/canvasTexture.ts
/src/videoTexture.ts动态的video
一个面的贴图需要对应的UV坐标,每一个顶点坐标添加对应的UV坐标,将他们一起通过顶点插槽传入Vertex Shader中。所以我们一般会将Position、UV以及Normal等多组数据放入一个VertexBuffer中。当然也可以将不同的数据创建多个VertexBuffer,设置多个顶点插槽传入Vertex Shader。
为了操作方便,在cube.ts中,我们将UV和顶点数据放在了一起,即每一行添加了两个UV坐标。需要注意的是这里虽然将它们放在了一起,看起来都是使用了1作为参数,但两者并没有什么关系。顶点Position的数据是根据我们选定的世界坐标系进行的建立,理论上可以是任何数值范围。而UV坐标是标准的0~1的坐标参考系。
这里的贴图相当于每个面都做了水平方向以及垂直方向的翻转。
//cube.ts
const vertex = new Float32Array([
// float3 position, float2 uv
// face1
+1, -1, +1, 1, 1,
-1, -1, +1, 0, 1,
-1, -1, -1, 0, 0,
+1, -1, -1, 1, 0,
+1, -1, +1, 1, 1,
-1, -1, -1, 0, 0,
// face2
+1, +1, +1, 1, 1,
+1, -1, +1, 0, 1,
+1, -1, -1, 0, 0,
+1, +1, -1, 1, 0,
+1, +1, +1, 1, 1,
+1, -1, -1, 0, 0,
// face3
-1, +1, +1, 1, 1,
+1, +1, +1, 0, 1,
+1, +1, -1, 0, 0,
-1, +1, -1, 1, 0,
-1, +1, +1, 1, 1,
+1, +1, -1, 0, 0,
// face4
-1, -1, +1, 1, 1,
-1, +1, +1, 0, 1,
-1, +1, -1, 0, 0,
-1, -1, -1, 1, 0,
-1, -1, +1, 1, 1,
-1, +1, -1, 0, 0,
// face5
+1, +1, +1, 1, 1,
-1, +1, +1, 0, 1,
-1, -1, +1, 0, 0,
-1, -1, +1, 0, 0,
+1, -1, +1, 1, 0,
+1, +1, +1, 1, 1,
// face6
+1, -1, -1, 1, 1,
-1, -1, -1, 0, 1,
-1, +1, -1, 0, 0,
+1, +1, -1, 1, 0,
+1, -1, -1, 1, 1,
-1, +1, -1, 0, 0
])
const vertexCount = 36
export {vertex, vertexCount}
对应的也需要对pipeline进行设置。
//imageTexture.ts
async function initPipeline(device: GPUDevice, format: GPUTextureFormat, size: { width: number, height: number }) {
const pipeline = await device.createRenderPipelineAsync({
//..
vertex: {
//...
buffers: [{
// 一行传入3个position和2个UV
arrayStride: 5 * 4, // 3 position 2 uv,
attributes: [
{
// position
shaderLocation: 0,
offset: 0,
format: 'float32x3'
},
// 添加对应的UV信息
{
shaderLocation: 1,
// 跳过前三个position
offset: 3 * 4,
// UV的长度
format: 'float32x2'
}
]
}]
},
这样我们可以在vertex shader中得到了@location(1)的UV坐标了。
//basic.vert.wgsl
//..
@stage(vertex)
fn main(
@location(0) position : vec4<f32>,
@location(1) uv : vec2<f32>
) -> VertexOutput {
var output : VertexOutput;
output.Position = mvpMatrix * position;
// 这个UV值我们并不会在Vertex Shader中进行处理,而是需要在Fragment Shader中对应贴图位置的时候才用得上。所以我们可以像之前的fragPostion一样,直接返回fragUV即可。
output.fragUV = uv;
output.fragPosition = 0.5 * (position + vec4<f32>(1.0, 1.0, 1.0, 1.0));
return output;
}
我们虽然在Fragment Shader中只返回了36个顶点的Position和UV值,但是经过光栅化处理后GPU会根据片元的位置自动对fragPosition还有发ragUV进行插值。所以在Fragment Shader中我们得到的fragPosition才是每个对应的像素的坐标位置。相应的,fragUV是每个平面像素的贴图坐标。
// position.frag.wgsl
@stage(fragment)
fn main(
@location(0) fragUV: vec2<f32>,
@location(1) fragPosition: vec4<f32>
) -> @location(0) vec4<f32> {
return fragPosition;
}
在Fragment Shader中操作UV和贴图信息
为了方便管理,相关代码统一放在了用户代码run中进行操作
// imageTexture.ts
async function run() {
const canvas = document.querySelector('canvas')
if (!canvas)
throw new Error('No Canvas')
const { device, context, format, size } = await initWebGPU(canvas)
const pipelineObj = await initPipeline(device, format, size)
/* 在Web中加载图片资源的两种方式
* 1.通过fetch直接请求图片的url,获取二进制的blob对象(推荐,blob的传输和内存管理更加高效,也省去了创建DOM的开销)(既可以在主线程中使用,也可以在worker中进行,提供了多线程加载资源的可能)
*/
// fetch an image and upload to GPUTexture
const res = await fetch(textureUrl)
const img = await res.blob()
/*
* 2.可以创建一个Image DOM,通过设置image.src来让浏览器加载资源(不能在worker中使用)
*/
// const img = document.createElement('img')
// img.src = textureUrl
// await img.decode()
Web中关于图片操作的相关实践经验
在常规开发中,我们应该优先选择webp作为图片资源。
也可以考虑使用一些还未普及的全新的图片格式,如JPEG XL/AVIF/WEBP2。
另外WebGPU原生支持一些专业的压缩格式,比如bc、etc、astc等等。但使用难度较大,还需要针对不同的设备或者操作系统单独进行压缩打包,并且WebGPU的代码也要做出相应的改变。
// imageTexture.ts - async function run
//...
// 用createImageBitmap() API将image对象转换为imageBitmap对象
// 这个API最初是JS为WebGL准备的资源对象,可以很方便地进行GPU传输和Canvas的绘制操作
const bitmap = await createImageBitmap(img)
const textureSize = [bitmap.width, bitmap.height]
// 但bitmap对象与matrix array一样,都是CPU中的资源数据,我们还需要在GPU创建对应的GPU对象才能被GPU使用
// create empty texture
const texture = device.createTexture({
size: textureSize, //array,对应图片的宽高
format: 'rgba8unorm',
usage:
//一般对应于附着在物体上的颜色贴图,我们首先要使用TEXTURE_BINDING:它可以被Group绑定,否则无法将它传入shader
GPUTextureUsage.TEXTURE_BINDING |
//与GPUBuffer一样,可以被JS写入数据更新
GPUTextureUsage.COPY_DST |
//作为Render的附件,可以被shader输出给颜色或者深度附件,否则贴图也无法显示
GPUTextureUsage.RENDER_ATTACHMENT
})
// 将创建好的GPUTexture对象写入
/* copyExternalImageToTexture API
* 将bitmap的全部内容拷贝给texture
* 需要用对象的形式来标注source、texture、还有拷贝的大小
* 注意,这个API跟writebuffer一样,也是同步API,JS会等待Copy的完成。如果copy的图片非常大,我们需要注意性能的开销。
*/
// update image to GPUTexture
device.queue.copyExternalImageToTexture(
{ source: bitmap },
{ texture: texture },
textureSize
)
// device.createSampler API创建sampler
// Create a sampler with linear filtering for smooth interpolation.
const sampler = device.createSampler({
// 可以分别设置U方向和V方向不同的address策略,默认是clamp-to-edge
// addressModeU: 'repeat',
// addressModeV: 'repeat',
//一般我们只设置采样缩放大小对应的filter类型
magFilter: 'linear',
minFilter: 'linear'
})
//将sampler和texture绑定到一个group中
const textureGroup = device.createBindGroup({
label: 'Texture Group with Texture/Sampler',
layout: pipelineObj.pipeline.getBindGroupLayout(1),//之前的mvpBuffer已经绑定到了layout(0)
entries: [
{
binding: 0,
resource: sampler
},
{
binding: 1,
//注意:bindGroup中绑定的resource对象必须是textureView,这里需要调用createView()
//texture和textureView的关系就像canvas和context的关系。一个是texture实例,一个是可以直接被GPU操作的逻辑对象。
resource: texture.createView()
}
]
//然后将这个group传入到renderPass中,通过setBindGroup将这两个资源共享给shader进行使用
})
在对应的Fragment Shader中,我们可以通过@group(1),获取到传入的sampler和texture,对应类型分别是sampler和texture_2d。因为texture中使用0~1的小数表示一个一个的像素颜色,所以还得加上
如何获取贴图数据?使用WGSL内置函数textureSample就可以返回UV对应的原始图像的像素颜色。每一个片元都对应返回一个贴图的像素颜色,合在一起就可以看到完整的贴图效果。我们也可以对返回的颜色进行处理,它本质上只是一个1×4的颜色数组,可以叠加光线、阴影等效果。比如这个demo中我们是直接将它和fragPosition进行相乘,相当于叠加上了之前的渐变颜色区间,所以最后的效果是彩色的贴图效果,如果不进行这个操作就是显示原始图片的颜色。
//imageTexture.frag.wgsl
@group(1) @binding(0) var Sampler: sampler;
@group(1) @binding(1) var Texture: texture_2d<f32>;
@stage(fragment)
fn main(@location(0) fragUV: vec2<f32>,
@location(1) fragPosition: vec4<f32>) -> @location(0) vec4<f32> {
return textureSample(Texture, Sampler, fragUV) * fragPosition;
}
我们添加了一个简单的canvas,其他的代码都一样。唯一的区别是copyExternalImageToTexture的source。除了静态的图片之外,WebGPU还支持canvas的对象,即我们可以直接把canvas画布上的内容直接拷贝给GPUTexture。有了Canvas理论上我们就可以利用JS生成任意的自定义的图案,包括动态的效果。这个例子中我们对canvas做了一个基本的鼠标画线条的交互,然后动态的更新cube的texture。
这里的核心是将copy的动作放在了frame的循环中进行,跟每一帧更新mvpBuffer一样。我们也可以每一帧去copy一次canvas,也就是动态的更新贴图,保持canvas和texture的内容同步,利用这种特性我们可以制作复杂的自定义图案,或者模拟动画的效果。我们也经常利用canvas对图片进行统一的缩放处理,然后再传递给GPU进行贴图。但需要注意的是,这种方法用的仍然是JS的同步API来进行写入数据的操作。如果数据量比较大或者频繁的写入是非常影响性能的,不是一种高效的动画解决方案。一般我们更多的是利用canvas制作复杂的自定义图形,偶尔的更新它,并不会用它来制作连续的动画效果。如果需要有大量的动画效果,我们还是推荐使用WebGPU原生支持的视频贴图。
// canvasTexture.ts
//..
function run(){
//...
function frame(){
// update texture from canvas every frame
device.queue.copyExternalImageToTexture(
{ source: canvas2 },
{ texture: texture },
textureSize
)
//...
}
}
我们可以直接使用video来作为texture的来源。
// videoTexture.ts
//首先,在JS中创建一个video的元素,加载一个浏览器原生支持的视频文件,让它自动循环播放
// set Video element and play in advanced
const video = document.createElement('video');
video.loop = true
video.autoplay = true
video.muted = true
video.src = videoUrl
await video.play()
浏览器原生支持的视频格式很多。市面主流是AVC(h264)。h265因版权问题很多浏览器不支持。一般推荐使用VP8/9的视频格式。未来可以考虑AV1这种新一代的视频格式同等画质下视频的体积将缩小一半。
Video贴图相对于普通贴图简单一些,不需要手动创建texture,设置texture的大小和用途,甚至不需要去写入贴图数据。WebGPU专门有一个importExternalTexture的API。
// videoTexture.ts
//..
asyn function run(){
//...
function frame(){
// external texture will be automatically destroyed as soon as JS returns
// cannot be interrupt by any async functions before renderring
// e.g. event callbacks, or await functions
// so need to re-load external video every frame
// 只有一个参数source,对应的是一个video的dom元素,会直接返回video当前播放帧对应的texture对象
const texture = device.importExternalTexture({
source: video
})
// also need to re-create a bindGroup for external texture
//通过group传入到Fragment Shader中进行采样
const videoGroup = device.createBindGroup({
layout: pipelineObj.pipeline.getBindGroupLayout(1),
entries: [
{
binding: 0,
resource: sampler
},
{
binding: 1,
resource: texture
//这里的external texture binding不需要createView()
}
]
})
}
}
第一,这个API只会返回视频播放的当前帧的截图,并不会随着video的播放而自动更新,我们还是需要在每一帧的循环里手动调用API,再次import texture才可以。只不过这个API是GPU内部直接复用了视频的buffer,而不是JS从CPU写入贴图的内容。所以相比较canvas动态copy来说,性能很好。
第二,此外这个API返回的texture和普通创建的texture生命周期不同。前面例子中手动创建的texture,如果不主动销毁或者不触发强制回收的话,它会一直存在。而这个API获得的texture只会临时存在在当前帧,也就是说,一旦浏览器进行了GPU绘制,或者video刷新到了下一帧,这个texture就会被立刻销毁回收。我们现在执行的importExternalTexture和draw是在同一个requestAnimationFrame的回调中进行的,也就是在device.queue.submit前texture才有效。一旦结束了本次requestAnimationFrame的回调,也就是本次draw的JS任务已经完成了,那么浏览器会立刻进行GPU刷新,相应的这个texture就会被立刻销毁,无法再次使用。所以我们才在每一帧的frame里去重新创造了texture和对应的group。因为这个video texture没办法在外部创建,它的生命周期只能保持在frame的回调函数中,且中间不能被任何的异步操作所打断。当然每一帧都临时创建一个新的group也是有一定的性能损耗的,但比起每一次用JS手动copy还是要好很多的。
第三,这种video texture的shader也是有少许的变化的。首先是类型不一样,对于视频贴图WGSL有专门的texture_external的这种类型。其次,因为这种texture的特殊性,WebGPU也使用了一个单独的textureSampleLevel API对视频进行采样。使用的时候需要注意一下名称,不要和普通的2D贴图混淆。其他的用法和结果都一样,我们还是可以对颜色做相关的处理。
//vedioTexture.frag.wgsl
@group(1) @binding(0) var Sampler: sampler;
@group(1) @binding(1) var Texture: texture_external;
@stage(fragment)
fn main(@location(0) fragUV: vec2<f32>,
@location(1) fragPosition: vec4<f32>) -> @location(0) vec4<f32> {
return textureSampleLevel(Texture, Sampler, fragUV) * fragPosition;
}