☰
片元着色器入门:从零理解GPU逐像素着色原理与WebGL实战
2026/10/3 18:52:08 网站建设 项目流程

这一篇我们聊片元着色器(Fragment Shader)。前面几篇把渲染管线和顶点着色器过了一遍之后,很多零基础读者真正卡住的地方就出现在这里:顶点着色器好歹还能和“坐标”“模型”联系起来,片元着色器一上来就面对一堆颜色、坐标、插值,完全不知道它在算些什么。但其实片元着色器是WebGL Shader里最容易出效果、也最能给你正反馈的部分。你不需要懂复杂的模型数据,一个覆盖全屏的三角形,就能画出来流动的渐变、翻涌的噪声、DEM高程设色,甚至常见的流体模拟效果,底层都有片元着色器的影子。这篇教程就围绕一个核心问题展开:片元着色器到底是怎么决定屏幕上每一个像素的颜色的,以及你该从哪里入手去改它、调它、玩它。

这篇内容适合三类人:刚看完顶点着色器还处于半懂状态的WebGL入门者;用Three.js做可视化但想深入底层,发现自定义材质无从下手的前端开发者;以及用Unity、Cesium、MagicaVoxel这些工具时被“shader怎么这么玄乎”劝退,想补上底层基础的同学。看完之后你会得到一个完整的最小可运行项目,并且能自己改出渐变、圆形、动态闪烁这些基础效果。

1. 片元着色器在渲染管线里的真实位置

1.1 一句话搞懂片元着色器在做什么

把现代GPU的渲染流程想象成一条流水线:前端是建模,中间是几何处理,后端是光栅化之后的逐像素上色。顶点着色器负责处理模型每个顶点的位置、法线、纹理坐标,它决定了“这个物体在屏幕上是什么形状”;片元着色器则负责处理覆盖在屏幕上的每一个片元(Fragment)的颜色,它决定了“这个形状的每一小块表面最终呈现出什么颜色”。

片元这个词,是GLSL规范里的正式叫法,在中文语境里经常被简称为“像素着色器”。严格来说,片元不等于像素:一个像素在最终显示时可能由多个片元混合而成,尤其在开启多重采样抗锯齿(MSAA)的时候,一个像素会生成多个子样本片元,分别执行片元着色器,最后再合并成一个像素。但你在写Shader时,可以先把它们当成同一个东西,后面做抗锯齿和半透明混合时再仔细区分。

我之前给零基础朋友打过一个比方:顶点着色器是给车定型的钣金师傅,负责把一块铁皮敲成车门;片元着色器是喷漆师傅,负责把车门刷成红色、带金属颗粒的那种红。钣金没做好,车门的形状就歪了;喷漆没喷好,整个质感就会崩塌。在WebGL高手圈子里有一句话:“顶点着色器决定形状,片元着色器决定质感”,这句话不夸张。

1.2 顶点着色器和片元着色器如何分工

这两种着色器最核心的区别,在于它们执行次数和数据来源完全不一样。

顶点着色器对每个顶点执行一次。一个三角形三个顶点,不管这个三角形在屏幕上覆盖了多少像素,顶点着色器都只跑三次。它只关心顶点的位置怎么变化、和模型相关的属性怎么传递。当顶点数量是几百个、几万个模型时,顶点着色器的执行次数就是几万次,这仍然是不大的数字。

片元着色器则完全不同。一个三角形覆盖屏幕上的区域,哪怕是全屏只画一个大三角形,片元着色器也要对覆盖到的每个片元执行一次。以1080p为例,1920乘1080大约是207万个像素,也就是说即使场景里只有一个巨大三角形,片元着色器也会运行约两百万次。如果你的场景里有复杂的几何体交叉、多个物体,这个数字会呈几何级数增长。

单次执行看起来都很快,但架不住次数多。所以在优化WebGL性能时,第一优先级永远是减少片元着色器的开销。比如片段里少用循环、少用if分支、不要在高精度浮点上做昂贵的运算,这些都是因为这个着色器会被执行天文数字级别的次数。而不是因为GLSL语法限制你这么做。

1.3 为什么画面质感由片元着色器决定

你看到的三维画面里,影响视觉感受的大多数元素都发生在片元阶段。

光照:一个表面受到光照后,颜色如何随视线方向、光源方向、法线方向变化,这些都是片元着色器里用漫反射模型、高光模型计算出来的。顶点着色器也可以算,但计算结果只插值到三角形内部,得到的往往是多边形感很强的“塑料脸”,所以实时光照都会放到片元级别去逐片元计算,体现更细腻的光影过渡。

纹理采样:给模型贴一张纹理图,在片元着色器里根据uv坐标取出对应颜色,这个过程叫纹理采样。uv经过GPU自动插值后,每个片元都拿到了自己对应的纹理坐标,因此能还原出照片级细节。

描边、马赛克、霓虹渐变、噪点、流场扰动、波形扭曲:这些后处理效果本质上都是把一个画面当作一张大纹理,然后在片元着色器里对每个屏幕片元做各种数学变换。

甚至一些貌似“几何”的效果也可以用片元着色器模拟。比如在ShaderToy上看到的那种,看起来像是雕刻出的3D模型,很多并不是真的三角形网格,而是用光线步进(Ray Marching)在片元着色器里对每个屏幕片元发射射线,去计算射线与物体表面的交点,然后做光照和着色。这也是片元着色器被称作“每像素编程”的原因。

2. 片元着色器的代码骨架与核心语法

2.1 一个最简片元着色器,逐行看懂

先看一段在WebGL 1里最基础、最常用的片元着色器:

precision mediump float; void main() { gl_FragColor = vec4(1.0, 0.5, 0.2, 1.0); }

只有三行有效代码,但每行都值得解释清楚。

第一行precision mediump float;是精度声明。GLSL要求你在片元着色器里明确说明浮点运算的精度,一般有三种:highp、mediump、lowp。移动端GPU对highp的支持参差不齐,所以在片元着色器里普遍用mediump。不写这一行,很多设备的驱动会直接编译报错。

第二行void main()是着色器入口函数,和C语言很像。着色器程序不需要你手动调用它,GPU在光栅化流程里会对每个片元自动执行一次main函数。

第三行gl_FragColor是WebGL 1内置的输出变量。它是一个vec4,也就是四个浮点数,分别代表红、绿、蓝、透明度,取值范围是0到1。注意,不是0到255,也不是0到100,而是0到1。所以vec4(1.0, 0.5, 0.2, 1.0)表示的是不透明的橙色,红色拉满、绿色一半、蓝色只有两成。

WebGL 2和OpenGL ES 3.0之后,推荐用自定义输出变量替代gl_FragColor:

precision mediump float; out vec4 fragColor; void main() { fragColor = vec4(1.0, 0.5, 0.2, 1.0); }

在out vec4 fragColor;里,out表示这是一个从着色器输出到下一个阶段的变量,名字可以自己取。很多教程为了兼容性和教学简单,还在用gl_FragColor,所以我这一篇的示例会以WebGL 1为主,你在看大多数在线Shader编辑器时也不会遇到困惑。

2.2 gl_FragCoord 到底是坐标还是像素

如果说gl_FragColor是片元着色器的“输出口”,那gl_FragCoord就是片元着色器的“输入口”之一。它是GLSL内置变量,类型是vec4,每个片元执行时都会自动带上自己的窗口坐标信息。

precision mediump float; void main() { // 红色分量用x坐标除以800,绿色分量用y坐标除以600 float r = gl_FragCoord.x / 800.0; float g = gl_FragCoord.y / 600.0; gl_FragColor = vec4(r, g, 0.0, 1.0); }

这段代码里,gl_FragCoord.x和gl_FragCoord.y就是当前片元在画布上的像素坐标,左下角是起点(0,0),右上角是(canvas宽度, canvas高度)。我把x坐标除800得到0到1之间的数作为红色分量,y坐标除600得到0到1之间的数作为绿色分量。

这里有个新手必踩的坑:gl_FragCoord的单位是物理像素,不是CSS像素。如果你在CSS里把canvas放大到页面的100%宽度,但canvas.width只设置了1280,那gl_FragCoord的取值范围就只到1280,超过1280的CSS像素区域会被拉伸或裁剪。所以做全屏效果时,一定要保证canvas.width和canvas.height与实际物理像素一致。这个问题会在后面的分辨率章节详细说。

另外,gl_FragCoord.z和gl_FragCoord.w也和深度、透视除法相关,但基础阶段用不到,可以先忽略。

2.3 varying插值:为什么三角形内部颜色是渐变

这是很多零基础读者最迷惑的点。我在顶点着色器里给了三个顶点三个不同的颜色,为什么渲染出来的三角形内部颜色是一段平滑渐变,而不是三个色块?

答案是两行代码之间隐藏的GPU自动插值。

顶点着色器输出一个varying变量,比如:

// 顶点着色器 attribute vec2 a_position; varying vec3 v_color; void main() { v_color = vec3(1.0, 0.0, 0.0); // 假设这个顶点输出红色 gl_Position = vec4(a_position, 0.0, 1.0); }

三角形有三个顶点,GPU分别执行三次这段代码,于是三个顶点各自输出了一个v_color值。光栅化阶段,GPU会把三角形覆盖到的每个片元都算一遍:它在三角形内部的位置越靠近红色顶点,拿到的v_color就越偏红;越靠近蓝色顶点,就越偏蓝。

varying变量在顶点着色器中写入,在片元着色器中读取,中间由GPU在硬件层面完成线性插值。这个机制非常简单、非常快,是整个渲染管线里最优雅的设计之一。

生活里也可以找到例子:你在一张白纸上涂三个点,红色、绿色、蓝色,然后用手指把它们抹开,交界处一定是平滑的渐变色,因为手指把三种颜色的颜料按距离混合了。GPU做插值本质上就是这个过程,只是速度极快,而且每个片元都精确地按重心坐标计算比例。

所以下一章我们在做PBR或者材质效果时,一定离不开varying,因为你想让模型的每个位置都有自己独立的法线、uv、世界坐标,而不是全屏统一数据。

3. 从黑屏到渐变——最小WebGL项目实战

3.1 搭建本地调试环境

这个阶段的重点不是搭建复杂的三维引擎,而是让你能最短路径地在浏览器里看到片元着色器运行结果。我推荐用本地静态服务器方式,因为以后写文件上传、纹理加载时,直接用file://协议打开会出现跨域问题。

如果你装了Node.js,可以在项目目录下运行:

npx serve .

装过Python的话更省事:

python3 -m http.server 8080

然后浏览器打开http://localhost:8080/,把你的HTML文件地址写进地址栏。

不想起本地服务器的话,直接在CodePen或ShaderToy这类在线编辑器里写也可以。Shadertoy的代码模板会自动帮你把iResolution、iTime这些uniform传好,非常适合做片元着色器练习。但为了不让你对“自动注入的uniform”产生依赖,这篇还是用原生的方式,把每一步都暴露在你面前。

3.2 核心代码拆解:编译、链接、绘制

下面是一段完整的HTML代码,它在全屏画布上绘制一个从左下角到右上角渐变的背景。你可以先直接跑起来,然后我们再慢慢拆。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>WebGL 片元着色器入门</title> <style> body { margin: 0; overflow: hidden; } canvas { display: block; width: 100vw; height: 100vh; } </style> </head> <body> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById('canvas'); const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); if (!gl) { throw new Error('当前环境不支持 WebGL'); } function resizeCanvas() { const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(window.innerWidth * dpr); canvas.height = Math.floor(window.innerHeight * dpr); canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px'; gl.viewport(0, 0, canvas.width, canvas.height); } resizeCanvas(); window.addEventListener('resize', resizeCanvas); const vsSource = ` attribute vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); } `; const fsSource = ` precision mediump float; uniform vec2 u_resolution; void main() { vec2 uv = gl_FragCoord.xy / u_resolution; vec3 color = vec3(uv.x, uv.y, 1.0 - uv.x); gl_FragColor = vec4(color, 1.0); } `; function createShader(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error('Shader 编译失败: ' + gl.getShaderInfoLog(shader)); } return shader; } const vs = createShader(gl.VERTEX_SHADER, vsSource); const fs = createShader(gl.FRAGMENT_SHADER, fsSource); const program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error('Program 链接失败: ' + gl.getProgramInfoLog(program)); } gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1 ]), gl.STATIC_DRAW); const aPosition = gl.getAttribLocation(program, 'a_position'); gl.enableVertexAttribArray(aPosition); gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); const uResolution = gl.getUniformLocation(program, 'u_resolution'); gl.uniform2f(uResolution, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 6); </script> </body> </html>

这段代码里最有必要拆开讲的有四个部分。

其一,resizeCanvas函数。它根据devicePixelRatio把canvas.width设为物理像素尺寸,同时保持CSS里的显示尺寸为100vw和100vh。这样可以避免高分屏下画面模糊,也不会让gl_FragCoord的坐标范围与CSS像素错位。我试过很多次,这个函数本身就是个坑:不处理dpr,在Mac Retina屏上画面会发虚;处理了dpr但忘记同步CSS尺寸,画布会出现奇怪的裁切。

其二,顶点着色器里输入的a_position是-1到1的裁剪空间坐标。两个三角形拼成一个大四边形,覆盖整个屏幕。坐标数据是:

-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1

前三个顶点构成一个左下角的三角形,后三个顶点构成右上角的三角形。这样整个四边形覆盖屏幕后,片元着色器就对全屏每个片元运行一次。

其三,createShader函数里的编译错误处理。GLSL编译失败不会像JavaScript那样抛出异常,而是静默地生成一个编译状态。你必须主动调用gl.getShaderParameter(shader, gl.COMPILE_STATUS)检查状态,再用gl.getShaderInfoLog(shader)拿到日志。这段代码我每一次写WebGL项目都会保留,它是排查片元着色器语法错误的第一道防线。

其四,gl.drawArrays(gl.TRIANGLES, 0, 6)。这里6是因为顶点数组里有6个顶点,组成了两个三角形。用TRIANGLES绘制时,GPU每3个顶点组成一个三角形,6个顶点刚好组成两个。

3.3 颜色渐变是如何算出来的

上述代码在片元着色器里最重要的两行是:

vec2 uv = gl_FragCoord.xy / u_resolution; vec3 color = vec3(uv.x, uv.y, 1.0 - uv.x);

gl_FragCoord.xy是当前片元的物理像素坐标,比如(1200, 800)。u_resolution是画布物理尺寸,比如(1920, 1080)。两者相除后得到uv,取值范围是0到1,表示当前片元在整个画布中的相对位置。

左下角坐标gl_FragCoord接近(0,0),uv也接近(0,0),所以color是vec3(0.0, 0.0, 1.0),偏蓝色。右上角坐标uv接近(1,1),所以color是vec3(1.0, 1.0, 0.0),黄色。中间位置的颜色就是这三个分量按比例混合的结果。

我建议你把颜色公式改成vec3(uv.x, 0.0, 0.0),你会看到一条从左到右的纯红色渐变;改成vec3(0.0, uv.y, 0.0),会看到一条从下到上的绿色渐变。这种“随手改动颜色公式、立刻观察变化”的练习模式,是整个入门阶段最有效的学习方法,比看十篇教程都管用。

4. 片元着色器核心逻辑的三个进阶练习

4.1 坐标映射:把屏幕变成0-1的uv坐标系

刚接触Shader的人,最容易在坐标问题上栽跟头。gl_FragCoord是像素坐标,范围从0到画布宽高,直接拿它做数学运算会出现一个问题:同一个效果在不同分辨率的屏幕上显示不一致。

解决办法就是上一节提到的归一化。把所有片元坐标都压到0到1之间,得到uv坐标。这样你的Shader逻辑就和分辨率解耦了,不管运行在1080p还是4K屏上,画出来的图形形状、颜色分布都保持一致。

precision mediump float; uniform vec2 u_resolution; void main() { vec2 uv = gl_FragCoord.xy / u_resolution; float dist = distance(uv, vec2(0.5, 0.5)); float circle = smoothstep(0.15, 0.14, dist); vec3 color = vec3(circle); gl_FragColor = vec4(color, 1.0); }

这里用distance函数算出当前片元和屏幕中心(0.5, 0.5)的距离,再用smoothstep在半径0.15附近做一个平滑过渡。smoothstep(edge0, edge1, x)的意思是:当x小于edge0时返回0,大于edge1时返回1,中间做平滑插值。写法上smoothstep(0.15, 0.14, dist)看起来很反直觉,但实际上是利用平滑过渡的边缘来产生抗锯齿圆形,边缘宽度就是0.15减0.14之间的范围。

这里补充一个细节:如果你用if (dist < 0.15)这种写法,也能画出圆形,但边缘会非常粗糙,产生锯齿。GPU光栅化产生的边缘锯齿只能通过这种平滑的数学函数消除,这也是为什么Shader编程和普通编程思维不一样——它鼓励你用数学函数代替分支,用连续变化代替离散判断。

4.2 时间驱动:用uniform把动画“喂”给GPU

Shader写好了,怎么让它动起来?答案是传入一个时间uniform,让每一帧传入的数值都不同。

JavaScript侧,你需要用requestAnimationFrame不停地更新这个值:

let startTime = performance.now(); function render(now) { const time = (now - startTime) / 1000.0; // 秒 gl.uniform1f(timeUniformLocation, time); gl.drawArrays(gl.TRIANGLES, 0, 6); requestAnimationFrame(render); } requestAnimationFrame(render);

GLSL侧,片元着色器里这样写:

precision mediump float; uniform vec2 u_resolution; uniform float u_time; void main() { vec2 uv = gl_FragCoord.xy / u_resolution; float r = 0.5 + 0.5 * sin(u_time + uv.x * 6.28318); float g = 0.5 + 0.5 * sin(u_time * 0.8 + uv.y * 6.28318); float b = 0.5 + 0.5 * sin(u_time * 1.2 + length(uv) * 6.28318); vec3 color = vec3(r, g, b); gl_FragColor = vec4(color, 1.0); }

sin函数的取值范围是-1到1,通过0.5 + 0.5 * sin(x)映射到0到1,正好对应颜色分量。关键点在于给不同分量设置不同的频率和相位:u_time * 0.8和u_time * 1.2让三个颜色通道的变化速度不一致,uv.x * 6.28318和uv.y * 6.28318则让颜色在空间方向也产生波动。

6.28318是2π的近似值。乘以它意味着每经过一个周期,sin内部的角度变化正好是一个完整圆周,这样画面会出现连续、平滑的循环波动,不会突然跳变。

这里有个经验性问题:很多初学者喜欢把时间乘以一个很大数字追求“快速变化”,结果画面闪成电焊现场。我建议初始阶段把时间速度因子控制在0.5到2.0之间,优先观察颜色流动的规律,再通过调参去逼近你想要的效果。

4.3 varying传值:给将来的模型纹理打好基础

全屏Shader的清晰之处在于不需要考虑顶点属性,直接把gl_FragCoord当输入。但你做的项目不可能一直都是全屏后处理,总会遇到给一个3D模型贴纹理、涂颜色的场景。这时候要让片元着色器拿到每个片元对应的纹理坐标,就必须从顶点着色器传值过来。

顶点着色器:

attribute vec2 a_position; varying vec2 vUv; void main() { vUv = a_position * 0.5 + 0.5; gl_Position = vec4(a_position, 0.0, 1.0); }

a_position的范围是-1到1,做a_position * 0.5 + 0.5后,范围变成0到1。这就是把裁剪空间坐标映射成纹理坐标uv的常见写法。

片元着色器:

precision mediump float; varying vec2 vUv; void main() { vec3 color = vec3(vUv.x, vUv.y, 1.0); gl_FragColor = vec4(color, 1.0); }

这里不再用gl_FragCoord,而是直接用从顶点着色器插值而来的vUv。渲染出来的效果和用gl_FragCoord / u_resolution的效果几乎一样,但原理完全不同。一个是片元自己算出来的屏幕坐标,一个是顶点着色器传值后由GPU插值得到的坐标。后者才是模型纹理采样的标准做法。

你还可以基于vUv画网格线:

precision mediump float; varying vec2 vUv; void main() { vec2 grid = abs(fract(vUv * 10.0) - 0.5); float line = 1.0 - step(0.02, min(grid.x, grid.y)); vec3 color = mix(vec3(0.1), vec3(1.0), line); gl_FragColor = vec4(color, 1.0); }

fract取小数部分,vUv * 10.0把屏幕分成10乘10的网格,abs(x - 0.5)保证网格线在格子两侧是对称的,step决定哪些位置的亮度高。这段代码体现了一个思维模式:用数学函数组合出几何图案,而不是用if分支逐格判断。

5. 常见问题与性能避坑实录

5.1 黑屏但不报错

这是新手阶段最令人抓狂的现象:网页打开,控制台干干净净,canvas区域一片漆黑。我总结了一下原因,按出现频率排序如下。

第一,片元着色器没有给所有路径写入颜色。比如你在某个if分支里写了gl_FragColor,但另一个分支没写,或者某些提前return的路径漏掉了赋值,最终渲染时GPU可能拿到一个未定义的颜色,显示为黑色。

第二,顶点数据范围不对。很多人习惯写0到1的坐标,但裁剪空间是-1到1,如果顶点都集中在屏幕左下角,看到的画面就接近全黑,只有角落有颜色。检查方法很简单:把顶点数据改成覆盖全屏的四个点或六个点。

第三,program没有生效。正确顺序是先gl.useProgram(program),再drawArrays。漏掉useProgram时,GPU使用的默认着色器程序什么都不画。

第四,gl.viewport没有同步画布尺寸。全屏Shader因为uv是相对坐标,可能看起来好像没太大影响,但如果你同时设置gl_Position和viewport不一致,画面会被裁剪错乱。

排查顺序我推荐这样:先在片元着色器里写死颜色,比如gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0),如果画面变红,说明数据流没问题,问题出在颜色公式或uniform传参上;如果还是黑屏,检查顶点数据、program链接状态和viewport。

5.2 GLSL语法容易踩的三个坑

GLSL和JavaScript虽然都长得像C语言,但细节上坑很多。

隐式类型转换不存在。float value = 1;在JavaScript里完全正常,但在GLSL里直接编译失败,必须写成1.0。同理,vec2(1, 2)也会报错,因为整型字面量不能直接当浮点数用。我一开始写Shader时几乎每周都会因为这种小问题浪费时间,后来索性写任何数字都带上小数点。

精度声明缺失。片元着色器里如果不写precision mediump float;,在很多设备的驱动上会报错。如果你用的设备是高通Adreno或者老一点的Mali GPU,这一类编译错误尤其常见。规范允许不声明,但实际终端设备对“默认精度”的实现并不统一,所以写的时候一定带上。

矩阵和向量的乘法顺序容易混淆。GLSL里vector * matrix和matrix * vector语义不同,前者是按列向量处理,后者按行向量处理。WebGL的习惯是使用列主序矩阵,代码里一般是vector * matrix。你暂时用不到矩阵运算,但后续做模型变换时会遇到这个坑,先记住结论。

5.3 分辨率与DPR的坑

全屏Shader最常见的模糊问题,罪魁祸首通常是canvas物理尺寸和CSS尺寸不一致。

从CSS角度来看,你设置了canvas { width: 100vw; height: 100vh; },浏览器会把canvas显示尺寸拉伸到窗口大小。但canvas.width和canvas.height属性决定的是画布里的像素数量。如果两者不一致,WebGL渲染到canvas.width那个分辨率,再由CSS拉伸到显示尺寸,就会出现模糊或锯齿。

正确做法我在这篇的代码里已经写进去了:用window.devicePixelRatio乘上窗口尺寸,再赋给canvas.width和canvas.height,同时把CCS尺寸设置为窗口尺寸。

另外,gl_FragCoord用的是物理像素坐标,不是CSS像素。如果你在片元着色器里把某一段距离硬编码为比如50.0,在普通屏上是50像素,在Retina屏上看起来只有25像素的视觉宽度。所以做尺寸相关的效果时,最好也把devicePixelRatio作为uniform传入着色器,或者直接用归一化后的uv计算。

5.4 性能建议速查表

情况建议
片元着色器里有循环尽量少用,循环次数固定的尽量展开写。GPU对动态循环支持差,最好避免
片元着色器里有if/else可用step、smoothstep、mix等数学函数替代分支
纹理采样次数太多每多一次纹理采样都消耗带宽,能预计算的先传给uniform
使用highp精度片元着色器里非必要不使用,mediump够用且更快
大数组、动态索引避免在片元着色器里读取动态索引的大数组,开销极大
依赖gl_FragCoord做全屏后处理如果用不上像素坐标,优先用varying插值传递uv,省去uniform传递

这套表是给后续做真实项目准备的。入门学习阶段不必过度优化,一个渐变背景就算写一百个分支也照样跑得飞起。但当你开始做粒子、流体模拟、复杂后处理时,片元着色器会成为整个渲染流程的瓶颈,这些性能原则就要提前内化成肌肉记忆。

另外提一个非常容易忽略的坑:片元着色器的变量精度不只是声明而已,它还会影响纹理坐标的插值精度。使用mediump时,uv在某些大尺寸贴图上可能出现轻微偏移或细纹现象,如果你观察到纹理在远处闪烁、抖动,可以先检查精度,必要时单独把uv声明为highp。

最后再分享一点个人体会

片元着色器是我个人觉得WebGL整个体系里最“好玩”的部分。它把编程从“逻辑堆叠”变成了“数学作画”,同样的一个圆形,放大坐标可以画出星球环,加噪点可以画出火焰纹理,加时间可以做呼吸光效。我一开始学的时候,就是每天打开一个空白的Shader模板,随意改几个数字,观察画面的变化,这种即时反馈带来的快乐比写完一整页业务代码强太多了。

对刚看完这篇的读者,我的建议只有一个:把你手头那个渐变动画的代码打开,随便改。改成红色分量为uv.x * uv.y,改成绿色分量为1.0 - uv.y,或者给三个通道加上不同的时间速度再看效果。改到你觉得“这个如果用于某个项目背景会很好看”为止,你的片元着色器入门就算真正完成了。下一篇,我会接着讲GLSL内置函数和更丰富的颜色数学玩法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询