标准设备坐标
3D空间转换为2D图形的过程
模型变换
矩阵计算
齐次坐标
Vertex shader的裁剪空间又称为齐次空间
正交投影、透视投影
正视图、后视图
视锥
从前发散、从后发散
投影矩阵
显示一个完整的3D图形经过的步骤
以坐标为原点,建立整个图形的顶点坐标信息
利用矩阵变换,将一个图形在世界空间坐标中进行坐标变换
利用投影矩阵,用摄像机和视锥模拟不同视角
将裁剪空间进行统一的归一化处理,抛弃NDC空间之外的图形
把整个NDC空间通过内部的视图变换,得到二维平面结果
最后的两步是由GPU驱动自动完成,不需要开发者的参与。我们需要考虑的主要是(第三步)在Vertex Shader中返回裁剪空间的计算结果。前两步主要是在用户的逻辑代码中进行的。
注意:
世界空间的坐标系可以和裁剪空间不同。因为我们引入了摄像机和视锥的概念,所以可以自由安排视锥体对应的坐标系结构。可以将整个空间的方向和大小按照我们的需求自定义修改。不需要强行的规范坐标系的范围和坐标轴的方向。所以一般的场景中我们会使用一个独立的坐标系来构建整个世界。
具体的顶点数据的处理过程。不推荐改变原始顶点数据的方法变换图形(不高效:数据写入、管线切换)。推荐如果模型没有变化就不改变顶点坐标,再额外准备一个GPU的Buffer专门存储矩阵的数据。可以将需要变换的ModelView Matrix和Projection Matrix独立的相乘计算出结果,形成一个统一的ModelViewProjection矩阵,把它通过BindGroup作为全局参数传入到Vertex Shader中。然后可以在Vertex Shader中将顶点的坐标信息和MVP矩阵相乘得到最终的结果。好处是原始数据没有改变,可以复用一个模型的顶点数据。JS矩阵计算是用CPU,把矩阵放到Shader中利用GPU的并行计算效率会好很多。
http://github.com/Orillusion/orillusion-webgpu-samples
/src/rotatingCube.ts
注:在triangle.ts的基础上进行的教学
triangle.ts(实际上是cube.ts)
1个cube需要6个矩形面进行组合,1个矩形至少需要2个三角面进行组合,1个三角面如果不是strip模式或者用index来绘制,则需要3个独立的点来构成,1个cube至少需要36个点位信息。
const vertex = new Float32Array([
// float3 position, float2 uv
// face1
+1, -1, +1,
-1, -1, +1,
-1, -1, -1,
+1, -1, -1,
+1, -1, +1,
-1, -1, -1,
// face
+1, +1, +1,
+1, -1, +1,
+1, -1, -1,
+1, +1, -1,
+1, +1, +1,
+1, -1, -1,
// face
-1, +1, +1,
+1, +1, +1,
+1, +1, -1,
-1, +1, -1,
-1, +1, +1,
+1, +1, -1,
// face
-1, -1, +1,
-1, +1, +1,
-1, +1, -1,
-1, -1, -1,
-1, -1, +1,
-1, +1, -1,
// face
+1, +1, +1,
-1, +1, +1,
-1, -1, +1,
-1, -1, +1,
+1, -1, +1,
+1, +1, +1,
// face
+1, -1, -1,
-1, -1, -1,
-1, +1, -1,
+1, +1, -1,
+1, -1, -1,
-1, +1, -1,
])
const vertexCount = 36
export {vertex, vertexCount}
colorTriangle.ts
本系列教程使用主流的gl-matrix作为矩阵的计算工具
在项目目录下的terminal通过npm install gl-matrix
进行安装。
import positionVert from './shaders/position.vert.wgsl?raw'
import colorFrag from './shaders/color.frag.wgsl?raw'
import * as triangle from './util/triangle'
import {mat4,vec3} from 'gl-matrix'
// initialize webgpu device & config canvas context
async function initWebGPU(canvas: HTMLCanvasElement) {
if(!navigator.gpu)
throw new Error('Not Support WebGPU')
const adapter = await navigator.gpu.requestAdapter()
if (!adapter)
throw new Error('No Adapter Found')
const device = await adapter.requestDevice()
const context = canvas.getContext('webgpu') as GPUCanvasContext
const format = navigator.gpu.getPreferredCanvasFormat ? navigator.gpu.getPreferredCanvasFormat() : context.getPreferredFormat(adapter)
const devicePixelRatio = window.devicePixelRatio || 1
canvas.width = canvas.clientWidth * devicePixelRatio
canvas.height = canvas.clientHeight * devicePixelRatio
const size = {width: canvas.width, height: canvas.height}
context.configure({
device, format,
// prevent chrome warning after v102
alphaMode: 'opaque'
})
return {device, context, format, size}
}
// create a simple pipiline & buffers(暂时不动)
async function initPipeline(device: GPUDevice, format: GPUTextureFormat,size:{width:number,height:number}) {
const pipeline = await device.createRenderPipelineAsync({
label: 'Basic Pipline',
layout: 'auto',
vertex: {
module: device.createShaderModule({
code: positionVert,
}),
entryPoint: 'main',
buffers: [{
arrayStride: 3 * 4, // 3 float32,
attributes: [
{
// position xyz
shaderLocation: 0,
offset: 0,
format: 'float32x3',
}
]
}]
},
fragment: {
module: device.createShaderModule({
code: colorFrag,
}),
entryPoint: 'main',
targets: [
{
format: format
}
]
},
primitive: {
topology: 'triangle-list', // try point-list, line-list, line-strip, triangle-strip?
cullMode:'back' //剔除背面
},
// 深度测试
// Enable depth testing since we have z-level positions
// Fragment closest to the camera is rendered in front
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus', //深度贴图的数据格式,指深度存储的精度
}
} as GPURenderPipelineDescriptor)
// 创建深度贴图
// create depthTexture for renderPass
const depthTexture = device.createTexture({
size, format: 'depth24plus',//贴图的大小应该和画布大小一致
usage: GPUTextureUsage.RENDER_ATTACHMENT,
})
// create vertex buffer(可以保留)
const vertexBuffer = device.createBuffer({
label: 'GPUBuffer store vertex',
size: triangle.vertex.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
//mappedAtCreation: true
})
device.queue.writeBuffer(vertexBuffer, 0, triangle.vertex)
// create color buffer(可以保留)
const colorBuffer = device.createBuffer({
label: 'GPUBuffer store rgba color',
size: 4 * 4, // 4 * float32
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
device.queue.writeBuffer(colorBuffer, 0, new Float32Array([1,1,0,1]))
//新建一个GPU Buffer用于存储MVP矩阵
const mvpMatrix = device.createBuffer({
size:4*4*4 //4*4的矩阵,外加矩阵可以是小数 16 float32
usage:GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
})
// create a uniform group for color
const uniformGroup = device.createBindGroup({
label: 'Uniform Group with colorBuffer',
layout: pipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: {
buffer: colorBuffer
}
},
{
binding: 1,
resource: {
buffer: mvpMatrix
}
}, // 这两个可以交换顺序
]
})
// return all vars
return {pipeline, vertexBuffer, colorBuffer, uniformGroup,mvpMatrix,depthTexture}
}
// create & submit device commands
function draw(device: GPUDevice, context: GPUCanvasContext, pipelineObj: {
pipeline: GPURenderPipeline,
vertexBuffer: GPUBuffer,
colorBuffer: GPUBuffer,
uniformGroup: GPUBindGroup,
depthTexture: GPUTexture,
}) {
const commandEncoder = device.createCommandEncoder()
const view = context.getCurrentTexture().createView()
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: view,
clearValue: { r: 0, g: 0, b: 0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store'
}
],
//深度模板附件
depthStencilAttachment: {
view: pipelineObj.depthView.createView(),
depthClearValue: 1.0,//全部清空
depthLoadOp: 'clear',
depthStoreOp: 'store',
}
}
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor)
passEncoder.setPipeline(pipelineObj.pipeline)
// set uniformGroup
passEncoder.setBindGroup(0, pipelineObj.uniformGroup)
// set vertex
passEncoder.setVertexBuffer(0, pipelineObj.vertexBuffer)
// 3 vertex form a triangle
passEncoder.draw(triangle.vertexCount)
passEncoder.end()
// webgpu run in a separate process, all the commands will be executed after submit
device.queue.submit([commandEncoder.finish()])
}
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)
const position = {x:0, y:0, z:-8},
const rotation = {x:0.5, y:0, z:0},
const scale = {x:1, y:1, z:1}
// first draw
function frame(){
rotation.x += 0.001
rotation.y += 0.001
const modelViewMatrix = mat4.create()
// translate position
mat4.translate(modelViewMatrix, modelViewMatrix, vec3.fromValues(position.x, position.y, position.z))
// rotate
mat4.rotateX(modelViewMatrix, modelViewMatrix, rotation.x)
mat4.rotateY(modelViewMatrix, modelViewMatrix, rotation.y)
mat4.rotateZ(modelViewMatrix, modelViewMatrix, rotation.z)
// scale
mat4.scale(modelViewMatrix, modelViewMatrix, vec3.fromValues(scale.x, scale.y, scale.z))
//设置projection matrix的API
const projectionMatrix = mat4.create()
mat4.perspective(projectionMatrix,Math.PI/2,size.width/size.height,1,100)
const mvpMatrix = mat4.create()
mat.multiply(mvpMatrix,projectionMatrix,modelViewMatrix)
device.queue.writeBuffer(pipelineObj.mvpMatrix,0,mvpMatrix as Float32Array)
/* ---- 注意以下顺序都不能打乱 ----
* MVP的顺序是projection乘以modelView
* ModelView是按照平移旋转缩放的顺序进行的
* shader里的计算是MVP乘以坐标
*/
draw(device, context, pipelineObj)
} // 间隔小于40ms就比较流畅了,过小不推荐(CPU&GPU计算、其他异步程序,主流显示器16.6,漏帧错帧
// update colorBuffer if color changed
document.querySelector('input[type="color"]')?.addEventListener('input', (e:Event) => {
// get hex color string
const color = (e.target as HTMLInputElement).value
console.log(color)
// parse hex color into rgb
const r = +('0x' + color.slice(1, 3)) / 255
const g = +('0x' + color.slice(3, 5)) / 255
const b = +('0x' + color.slice(5, 7)) / 255
// write colorBuffer with new color
device.queue.writeBuffer(pipelineObj.colorBuffer, 0, new Float32Array([r, g, b, 1]))
draw(device, context, pipelineObj)
//开启动画循环
requestAnimationFrame(frame)
}
//浏览器UI刷新之前进行一次回调
requestAnimationFrame(frame)
// update vertexBuffer
document.querySelector('input[type="range"]')?.addEventListener('input', (e:Event) => {
// get input value
const value = +(e.target as HTMLInputElement).value
console.log(value)
// chagne vertex 0/3/6
triangle.vertex[0] = 0 + value
triangle.vertex[3] = -0.5 + value
triangle.vertex[6] = 0.5 + value
// write vertexBuffer with new vertex
device.queue.writeBuffer(pipelineObj.vertexBuffer, 0, triangle.vertex)
draw(device, context, pipelineObj)
})
// re-configure context on resize
window.addEventListener('resize', ()=>{
canvas.width = canvas.clientWidth * devicePixelRatio
canvas.height = canvas.clientHeight * devicePixelRatio
// don't need to recall context.configure() after v104
draw(device, context, pipelineObj)
})
}
run()
Vertex Shader : prosition.vert.wgsl
//使用color的形式在Vertex Shader中通过@group(0)@binding(1)的形式来获取MVP矩阵
@group(0) @binding(1) var<uniform>mvp : mat4x4<f32>;
//自定义返回结构
struct VertexOutput{
@builin(posotion) Position : vec4<f32>,
@location(0) fragPosition: vec4<f32>
};
@stage(vertex)
fn main(@location(0) position : vec4<f32>) -> VertexOutput {
var out: VertexOutput;
out.position = mvp * position;
out.fragPosition = 0.5 * (position + vec4(1.0,1.0,1.0,1.0); //避免数值太多小于0的,所以有大片的黑色
return out;
}
/* 注意:
* 传入的position是一个1*3的数组,是不能和4*4的矩阵直接相乘的
* 法1:return mvp * vec4(position, 1.0);
* 法2:把cube.ts所有的坐标数据都+1变成vec4,但需要去调整管线的相关设置
* 法3:把position直接写为vec4【@location(0) position : vec4】
* mvp * position顺序不能颠倒(矩阵左乘右乘的区别)
通过mat4.perspective API创建符合透视空间的投影矩阵
调整物体位置
fragment shader : color.frag.wgsl
@group(0) @binding(0) var<uniform> color : vec4<f32>;
@stage(fragment)
fn main(@location(0) fragPosition:vec4<f32>) -> @location(0) vec4<f32> {
//WGSL中引入的group变量必须使用,否则会报错
var a = color;
return fragPosition;
}
/* Box的颜色变成了RGB的过渡区间
* Vertex Shader会把数据传给fragment shader
* Vertex Shader是根据顶点数量进行的并行运行,而fragment shader的是根据片源(像素点)数量
* 每个三角面的3个顶点的数据可以理解为是vertex shader的直接返回的
* 被三角面框起来的中间这些点的数据:GPU默认会采取的插值方法来处理每个片源对应的数据;3个点之间的数据会根据3个顶点的数值做线性插值
* 返回的每个点的坐标对应到fragment shader里其实就是每个片源或者像素点在空间里对应的插值坐标(范围-1~1)
* 所以如果我们直接将fragPosition返回作为颜色,自然就可以得到一个这样的过度颜色区间
*/
×:可以手动的调节整个顶点的结构,优化绘制顺序,以保证整个图形是按照深度的顺序来进行绘制图形(不高效)
√:开启深度测试。深度测试属于渲染管线中的一个环节,默认是不开启的。如果开启管线会在传入fragment shader之前,将各个片元进行z轴的深度对比。如果有相互遮挡的片元,深度较大的片源(离屏幕相对远的片源将会被舍弃)。没有开启深度测试情况下,没有被遮挡的面是内部面。