2、图形管线与着色器:图形渲染管线概述、顶点着色器与片元着色器的作用、编写第一个顶点着色器和片元着色器、着色器的编译与链接

好,咱们今天聊聊图形管线。说白了,它就是GPU干活的一套流水线流程。你想想看,我们在三维场景里摆了一堆模型,有立方体、有球体、有角色,这些数据最终是怎么变成屏幕上那些五彩斑斓的像素的?嗯,就是靠这条管线。

我个人习惯把图形管线想象成一条工厂流水线。原材料是顶点数据,经过一道道工序,最后产出成品——也就是你看到的画面。这个过程中,有两个工序特别关键,那就是顶点着色器片元着色器。它们就像是流水线上的两个核心工位,一个负责处理顶点,一个负责处理像素。

图形渲染管线概述

先看个整体流程。现代图形管线大致分这么几步:

  1. 顶点数据输入:把模型的顶点位置、颜色、法线、纹理坐标等数据送进管线。
  2. 顶点着色器:对每个顶点执行一次程序。主要干两件事——计算顶点的最终位置,以及传递后续需要的数据(比如颜色、纹理坐标)。
  3. 图元装配:把顶点组装成三角形、线段或点。这一步是硬件自动完成的。
  4. 光栅化:把三角形转换成屏幕上的像素片段。说白了,就是确定哪些像素被这个三角形覆盖了。
  5. 片元着色器:对每个像素片段执行一次程序。决定这个像素最终显示什么颜色。
  6. 逐片元操作:深度测试、模板测试、混合等。决定哪些像素能真正显示出来。
  7. 帧缓冲区:最终输出到屏幕。

核心要点:整个管线中,只有顶点着色器和片元着色器是我们可以编程控制的。其他步骤都是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光照模型,保证干货满满。