2、图形管线与着色器:图形渲染管线概述、顶点着色器与片元着色器的作用、编写第一个顶点着色器和片元着色器、着色器的编译与链接
好,咱们今天聊聊图形管线。说白了,它就是GPU干活的一套流水线流程。你想想看,我们在三维场景里摆了一堆模型,有立方体、有球体、有角色,这些数据最终是怎么变成屏幕上那些五彩斑斓的像素的?嗯,就是靠这条管线。
我个人习惯把图形管线想象成一条工厂流水线。原材料是顶点数据,经过一道道工序,最后产出成品——也就是你看到的画面。这个过程中,有两个工序特别关键,那就是顶点着色器和片元着色器。它们就像是流水线上的两个核心工位,一个负责处理顶点,一个负责处理像素。
图形渲染管线概述
先看个整体流程。现代图形管线大致分这么几步:
- 顶点数据输入:把模型的顶点位置、颜色、法线、纹理坐标等数据送进管线。
- 顶点着色器:对每个顶点执行一次程序。主要干两件事——计算顶点的最终位置,以及传递后续需要的数据(比如颜色、纹理坐标)。
- 图元装配:把顶点组装成三角形、线段或点。这一步是硬件自动完成的。
- 光栅化:把三角形转换成屏幕上的像素片段。说白了,就是确定哪些像素被这个三角形覆盖了。
- 片元着色器:对每个像素片段执行一次程序。决定这个像素最终显示什么颜色。
- 逐片元操作:深度测试、模板测试、混合等。决定哪些像素能真正显示出来。
- 帧缓冲区:最终输出到屏幕。
核心要点:整个管线中,只有顶点着色器和片元着色器是我们可以编程控制的。其他步骤都是GPU硬件固定好的。所以,想玩转WebGL,就得把这两个着色器吃透。
顶点着色器的作用
顶点着色器,顾名思义,就是处理顶点的。每个顶点都会调用一次。它的主要职责有:
- 坐标变换:把模型坐标转换成屏幕坐标。通常要经过模型矩阵、视图矩阵、投影矩阵的层层变换。
- 数据传递:把顶点相关的数据(颜色、法线、纹理坐标等)传递给片元着色器。
- 逐顶点计算:比如顶点光照、顶点动画等。
我在项目中遇到过一个问题:一个角色模型有上万个顶点,顶点着色器里做了很复杂的骨骼动画计算。结果在低端手机上帧率直接掉到个位数。后来我把部分计算挪到了CPU端预处理,才解决了问题。嗯,这里要注意——顶点着色器虽然只处理顶点,但顶点数量一多,计算量也是不容小觑的。
片元着色器的作用
片元着色器,也叫像素着色器。它处理的是光栅化后的每个像素片段。它的主要职责有:
- 颜色计算:决定每个像素的最终颜色。可以基于纹理采样、光照计算、颜色插值等。
- 纹理采样:从纹理图片中获取颜色值。
- 逐像素效果:比如法线贴图、阴影计算、后期特效等。
你想想看,一个1920x1080的屏幕,片元着色器要执行超过200万次。所以片元着色器的效率至关重要。我曾经在片元着色器里写了一个复杂的循环,结果画面直接卡成PPT。从那以后,我写片元着色器都格外小心,能用查表解决的绝不用循环。
小技巧:调试着色器时,可以在片元着色器里直接输出一个固定颜色(比如红色),看看模型是否正常显示。如果能看到红色模型,说明顶点着色器和管线流程没问题,问题出在片元着色器的颜色计算上。
编写第一个顶点着色器和片元着色器
好,理论说完了,咱们动手写代码。先写一个最简单的着色器对——只显示纯色。
顶点着色器(vertex shader)
// 顶点着色器源码
attribute vec4 a_position;
uniform mat4 u_modelViewMatrix;
uniform mat4 u_projectionMatrix;
void main() {
// 标准坐标变换
gl_Position = u_projectionMatrix * u_modelViewMatrix * a_position;
}
这个着色器只做了一件事:把顶点坐标从模型空间变换到裁剪空间。gl_Position是内置变量,表示顶点最终的位置。
片元着色器(fragment shader)
// 片元着色器源码
precision mediump float;
uniform vec4 u_color;
void main() {
// 输出一个固定颜色
gl_FragColor = u_color;
}
这个片元着色器更简单,直接输出一个颜色值。gl_FragColor是内置变量,表示像素的最终颜色。
注意:WebGL的片元着色器必须声明精度。一般用precision mediump float;就够了。高精度(highp)在某些设备上不支持,低精度(lowp)又容易丢失精度。mediump是个稳妥的选择。
着色器的编译与链接
着色器写好了,怎么让GPU运行它们?嗯,这里有个固定的流程:编译、链接、使用。
看代码:
// 1. 创建着色器对象
function createShader(gl, type, source) {
var shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
// 检查编译状态
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('着色器编译错误:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
// 2. 创建程序对象并链接
function createProgram(gl, vertexShader, fragmentShader) {
var program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
// 检查链接状态
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('程序链接错误:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
// 3. 使用
var vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource);
var fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
var program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program);
这个流程我闭着眼睛都能写出来。为什么?因为每个WebGL项目都得来一遍。我个人习惯把这段代码封装成一个工具函数,放在一个单独的文件里,每次新建项目直接复制粘贴。
编译失败怎么办?最常见的原因是语法错误。比如少了个分号,或者变量名拼错了。我曾经花了一个小时找bug,最后发现是attribute拼成了attribut。嗯,这种低级错误,谁还没犯过呢?
避坑指南:链接成功后,别忘了用gl.deleteShader()释放着色器对象。虽然不释放也不会报错,但会浪费内存。我见过一个项目,每帧都创建新的着色器却不释放,最后浏览器直接崩溃。从那以后,我每次创建完程序都会顺手把着色器删掉。
好,总结一下今天的内容。图形管线是GPU的流水线,顶点着色器和片元着色器是我们能编程的两个关键环节。顶点着色器处理顶点,片元着色器处理像素。写着色器要经过编译和链接两个步骤。这些基础打牢了,后面学光照、纹理、阴影就会轻松很多。
下一章,咱们聊聊如何用着色器实现光照效果。到时候我会分享一个我在实际项目中用过的PBR光照模型,保证干货满满。