学习笔记 | Orillusion-WebGPU小白入门(六)

(六)Texture

1.基础介绍

学习笔记 | Orillusion-WebGPU小白入门(六)_第1张图片

贴图:将图片的内容贴在物体的表面上,对应片元的位置,输出原图片对应的像素信息。

GPU如何匹配平面坐标和图片的像素位置

贴图的坐标系:与NDC坐标一样,WebGPU或者现代图形API也规范了一套贴图的坐标系,用来描述贴图的大小和位置信息。

以普通2D贴图为例:

图片的左上角对应原点(0,0),向右和向下为正方向,一般用字母U和V来做表示。为了适应不同的贴图大小,在这个UV坐标系中,图片的最大的宽和高都被归一化用1来做表示,即(1,1)坐标对应的是图片的右下角。

学习笔记 | Orillusion-WebGPU小白入门(六)_第2张图片

这样我们就可以通过描述一个面顶点的UV坐标,来定位图片在一个面中的位置和大小。

学习笔记 | Orillusion-WebGPU小白入门(六)_第3张图片

除此之外,我们还可以通过UV进行放大缩小原始图片。

学习笔记 | Orillusion-WebGPU小白入门(六)_第4张图片

空白区域如何匹配到原始图像

WebGPU或者现代图形API对此有一个Address的配置,也就是如何处理贴图周围的像素点。默认是clamp-to-edge(复用纹理边缘的像素信息)。除此之外还有repeat(超出1.0的部分将重复循环原始图案,即平铺)、mirror-repeat(超出1.0的部分会左右或上下翻转之后再做平铺,即镜像重复)。

实际操作中可以单独设置水平方向或者垂直方向不同的address方案,来组合显示不同的效果。

学习笔记 | Orillusion-WebGPU小白入门(六)_第5张图片

真实场景中还可能因为3D空间的透视变化,导致贴图内容的大小变化也不一样,近大远小对贴图同样适用。

学习笔记 | Orillusion-WebGPU小白入门(六)_第6张图片

Sampling 采样或重采样

UV坐标是理论0~1的连续空间,但真实的图像是一个个离散的像素点。如果原图和贴图的分辨率大小不一样,该如何去匹配具体的像素点内容?

Sampling (重)采样是数字信号处理的一个基本概念,任何数据包括音频视频图像等等,只要输出的大小或者频率跟原始的数据不一致,都要对原始数据进行采样或重采样。

放大或缩小在GPU内部中就是应用不同的filter。图形学API底层内置了很多相关算法,不需要我们自己去实现采样的过程。

学习笔记 | Orillusion-WebGPU小白入门(六)_第7张图片

目前WebGPU可以直接选择两种采样方法来处理像素点的变化。

NearestFilter 临近点策略

缩放后的每一个像素点都会根据UV差值形成一个新的坐标。

学习笔记 | Orillusion-WebGPU小白入门(六)_第8张图片

学习笔记 | Orillusion-WebGPU小白入门(六)_第9张图片

如果相邻的两个像素点距离一样,主流图形学算法会四舍五入优先选择像素点大的坐标,会选择x坐标y坐标更大的像素点,(正方向夹角的方向)。

学习笔记 | Orillusion-WebGPU小白入门(六)_第10张图片

学习笔记 | Orillusion-WebGPU小白入门(六)_第11张图片

同理缩小算法的过程也一样。首先找到UV坐标对应的新的坐标点,然后做距离对比。

学习笔记 | Orillusion-WebGPU小白入门(六)_第12张图片

边缘的颜色会比较明显,但误差也比较大,会造成比较明显的像素点的信息缺失。好处是策略简单,GPU的计算量很小,是一种性能优先的缩放采样方法。WebGPU也是默认使用这种策略进行采样缩放的。

LinearFilter 线性采样

首先找到原始坐标找到原始图像中对应的坐标点。与临近策略不同的是,它选取的不是最近的一个点,而是选择最近的四个像素点的颜色来做加权平均,即根据4个点距离大小的占比不同来混合计算最后的颜色。

学习笔记 | Orillusion-WebGPU小白入门(六)_第13张图片

学习笔记 | Orillusion-WebGPU小白入门(六)_第14张图片

学习笔记 | Orillusion-WebGPU小白入门(六)_第15张图片

线性计算的过程更复杂一些,计算量也更大一些,但最后的效果相对比,更加符合原始图像,像素点的过度比较均匀,但也一定程度上弱化了边缘的界限。

总结

实际操作过程中,如果原始图像分辨率足够,一般无论使用哪种方法进行缩小的操作,肉眼都很难看出区别,但如果是放大的话效果就比较明显了。临近采样会在边缘呈现非常明显的锯齿感(马赛克效果),而线性采样边缘过渡比较平滑,但也在一定程度上造成了边缘模糊的情况。

学习笔记 | Orillusion-WebGPU小白入门(六)_第16张图片

两者的最终效果不一定谁好谁劣,只是适用的内容不同。一般地,临近采样更适合边缘分割明显,颜色区域少的图像。而颜色丰富,过度较多的图,用线性采样的效果更好。

社区中有很多采样优化算法, 但考虑到GPU的运行效率问题,主流图形API中只内置了这两种方法。其他的优化采样算法,我们可以在shader中自己进行实现。

一般来说,比起复杂的算法优化,提高原始图像的分辨率,实际的收益效果可能会更明显一些。

2.Demo

https://github.com/Orillusion/orillusion-webgpu-samples

/src/imageTexture.ts

/src/canvasTexture.ts

/src/videoTexture.ts动态的video

静态图片 imageTexture.ts

设置UV坐标

一个面的贴图需要对应的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和贴图信息

在JS中如何创建和管理贴图

为了方便管理,相关代码统一放在了用户代码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中关于图片操作的相关实践经验

学习笔记 | Orillusion-WebGPU小白入门(六)_第17张图片

在常规开发中,我们应该优先选择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 canvasTexture.ts

我们添加了一个简单的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
        )
        //...
    }
}

动态视频 videoTexture.ts

我们可以直接使用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这种新一代的视频格式同等画质下视频的体积将缩小一半。

学习笔记 | Orillusion-WebGPU小白入门(六)_第18张图片

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;
}

你可能感兴趣的:(WebGPU学习笔记,学习)