WebGL入门系列走到第37篇。前面已经聊过三角形渲染、矩阵变换、纹理采样这些基础今天这篇内容不大但非常实用怎么在WebGL里画一个圆形的点point。先解释为什么会有这个需求。不管是在散点图里做数据标记还是在粒子系统里画雪花、光斑、雨滴我们都不希望看到一个个方块形状的点。但WebGL用gl.POINTS模式画点时默认输出的就是屏幕上一个正方形区域业内管它叫“点精灵”。你设置多大的gl_PointSize屏幕上就是多大的一个方块。真正的工作发生在片元着色器里——每个像素执行时我们判断它离点中心有多远距离超过半径就丢弃或者做半透明处理方块就变成了圆。这篇就把这个思路从原理讲到完整可运行的代码再讲到我实际踩过的几个坑。本篇适合已经把WebGL基础管线跑通、清楚gl_Position和attribute用法的读者。如果你刚接触WebGL建议先把前面的入门篇看完再回来看这篇效果会更好。1. 点为什么是方的gl.POINTS的底层逻辑1.1 点精灵到底是怎么生成的在WebGL里绘制点是最简单的绘制方式之一。调用gl.drawArrays(gl.POINTS, 0, n)传入的n个顶点每一个都会被当成一个独立的“点”来渲染。顶点着色器对每个顶点执行一次设置gl_Position和gl_PointSize紧接着GPU在光栅化阶段并不会去画一个完美的数学圆点而是直接以这个顶点为中心生成一块边长为gl_PointSize的方形区域。这个方块就是“点精灵”。落在方块区域内的每个像素都会执行一次片元着色器。这里要特别说明一个容易混淆的地方gl_PointSize是在顶点着色器里赋值的内建变量它决定方块尺寸而gl_PointCoord是在片元着色器里读取的内建变量它告诉我们“当前这个像素在方块里的位置”。gl_PointCoord的取值范围是[0,1]×[0,1]。举个例子一个恰好位于点区域中心位置的像素它的坐标约是(0.5, 0.5)。用生活化的比喻来说GPU收到的指令是“在屏幕上点亮一块方形区域”你手里有一个圆形模具。模具就是片元着色器里的数学判断用这个模具把方形多余的部分剪掉剩下的就是圆形。1.2 核心思路一句length()搞定圆形有了gl_PointCoord把正方形变成圆就变成一道初中几何题。以点区域的中心(0.5, 0.5)为圆心计算当前像素到圆心的距离vec2 uv gl_PointCoord - vec2(0.5); float dist length(uv);如果dist大于0.5说明这个像素落在圆外直接discard丢掉如果dist小于等于0.5说明在圆内保留并着色。这就是“圆形的点”的全部数学原理。为什么半径是0.5因为gl_PointCoord的坐标范围是0到1边长是1个单位。圆要内切于这个正方形直径就必须是1半径自然是0.5。正方形四个角上的像素到中心的距离大约是sqrt(0.5² 0.5²) ≈ 0.707全部大于0.5所以四个角会被干净地切掉。整个过程不需要计算cos、sin也不需要贴图一个length函数就画出一个圆。1.3 一个关键前提gl_PointSize必须大于1前面说过gl_PointSize不设置时默认值是1。1个像素的点无论你在片元着色器里怎么判断距离屏幕上看到的都是一个像素大小的点根本看不出圆不圆。想看到明显的圆形效果点大小至少要十几到几十像素。另外gl_PointSize不是你想设多大就设多大不同设备有各自的硬件上限后面“常见问题”里我会专门讲这个坑。2. 从“能看”到“好看”圆点边缘的抗锯齿处理2.1 直接discard会得到什么最简单的圆形点写法是这样vec2 uv gl_PointCoord - vec2(0.5); float dist length(uv); if (dist 0.5) discard; gl_FragColor vec4(1.0, 0.2, 0.2, 1.0);运行起来确实是个圆但边缘会有明显的锯齿。原因是像素是离散的网格圆边缘不可能和网格完美对齐。有的像素完全在圆外被砍掉有的像素只有一半在圆内却保留了完整颜色视觉上就会产生阶梯状的硬边。这种“硬边锯齿”在点精灵比较小的时候尤其明显——点一大相对锯齿没那么扎眼点一小边缘几个像素的起伏几乎占满了整个形状。所以入门可以先用discard把流程跑通但实际项目中很少直接这样用因为视觉质量太粗糙了。2.2 用smoothstep做边缘渐变抗锯齿的思路其实很朴素不要在0.5这个距离上做一刀切而是让alpha从一个很小的过渡带里平滑地变到0。GLSL里最方便的函数是smoothstep它能在两个边界值之间做三次Hermite插值过渡效果比线性插值更自然。float alpha 1.0 - smoothstep(0.5 - aa, 0.5 aa, dist);参数aa是过渡带的宽度。很多人一开始会手写一个固定值比如0.02但这样做有个问题过渡带宽度应该和像素大小挂钩。点精灵是20像素时0.02的过渡带在归一化坐标里对应不到0.5个像素点精灵是200像素时同样的0.02就对应好几个像素边缘会显得发虚。更稳的做法是让过渡带自适应。dist在屏幕空间的变化速率可以用fwidth函数拿到float aa fwidth(dist); float alpha 1.0 - smoothstep(0.5 - aa, 0.5 aa, dist);fwidth返回值是变量在相邻像素之间的变化率近似等于当前像素“在屏幕上有多大”。用它做过渡带宽度圆边在任何尺寸下都保持大约一个像素的渐变。这是目前最简单也最通用的点精灵抗锯齿方案。2.3 抗锯齿算出来的半透明别忘了开启混合用smoothstep把alpha算出来之后如果直接把vec4(color, alpha)交给gl_FragColor会发现圆边还是硬的。原因很常见WebGL默认不启用混合。对于半透明的像素默认行为是直接覆盖现有颜色。所以必须显式开启混合gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);这两行的含义是新像素颜色乘以自身alpha加上背景颜色乘以(1 - alpha)完成标准透明合成。开启混合之后圆的边缘才会和背后的背景色自然过渡。这里有一条经验性的建议如果场景背景是不透明的实色而且圆点数量巨大比如上万粒子可以改用discard方案而不是alpha混合方案。discard不会走混合流程对大数量绘制更友好代价是边缘抗锯齿要靠其他手段。但在绝大多数项目里我还是推荐alpha 混合视觉质量高而且几乎不会遇到驱动兼容性问题。3. 完整可运行的Demo圆形点的全部代码3.1 准备渲染环境与编译着色器为了让代码可以直接复制运行我用比较传统的方式写完整示例。先获取WebGL上下文再写两个着色器字符串然后封装一个编译工具函数。这里的封装逻辑是WebGL入门常用的套路创建shader、上传源码、编译、检查状态然后创建program并链接。function compileShader(gl, type, source) { const shader gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader)); } return shader; } function createProgram(gl, vsSource, fsSource) { const vs compileShader(gl, gl.VERTEX_SHADER, vsSource); const fs compileShader(gl, 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(gl.getProgramInfoLog(program)); } return program; }如果项目要兼容很老的浏览器不建议用模板字符串包裹着色器而是把着色器源码放到HTML里的script typex-shader/x-vertex标签中再用textContent读取。这里为了方便阅读统一用字符串。3.2 顶点着色器与片元着色器源码顶点着色器只做两件事把attribute位置传给gl_Position把uniform点大小传给gl_PointSize。因为我们画的是二维点z直接写0w写1坐标在NDC下直接用即可。// 顶点着色器 attribute vec2 a_Position; uniform float u_PointSize; void main() { gl_Position vec4(a_Position, 0.0, 1.0); gl_PointSize u_PointSize; }片元着色器里就是核心圆形判断。uniform u_Color控制点的颜色u_Softness控制边缘柔和程度默认按1处理。这个参数在后面做粒子光晕时很有用。// 片元着色器 precision mediump float; uniform vec3 u_Color; uniform float u_Softness; void main() { vec2 uv gl_PointCoord - vec2(0.5); float dist length(uv); float aa fwidth(dist) * u_Softness; float alpha 1.0 - smoothstep(0.5 - aa, 0.5 aa, dist); gl_FragColor vec4(u_Color, alpha); }有两点需要提醒。一是片元着色器里必须声明float精度否则在部分设备上会编译失败错误信息有时候还很隐蔽。二是不同平台下gl_PointCoord的y方向可能不一样但圆形判断是中心对称的方向完全不影响结果所以放心用。3.3 设置缓冲区并绘制一个点一个点只需要一个顶点缓冲区里放2个float就够了。这里把坐标设为(0.0, 0.0)也就是视口正中心。上传到GPU后绑定顶点属性再调用gl.drawArrays(gl.POINTS, 0, 1)。const canvas document.getElementById(webgl-canvas); const gl canvas.getContext(webgl); const program createProgram(gl, vsSource, fsSource); gl.useProgram(program); const aPosition gl.getAttribLocation(program, a_Position); const uPointSize gl.getUniformLocation(program, u_PointSize); const uColor gl.getUniformLocation(program, u_Color); const uSoftness gl.getUniformLocation(program, u_Softness); const buffer gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0.0, 0.0]), gl.STATIC_DRAW); gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(aPosition); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.clearColor(0.05, 0.08, 0.12, 1.0); gl.clear(gl.COLOR_BUFFER_BIT); gl.uniform1f(uPointSize, 80.0); gl.uniform3f(uColor, 0.95, 0.35, 0.25); gl.uniform1f(uSoftness, 1.0); gl.drawArrays(gl.POINTS, 0, 1);在HTML里放一个canvas idwebgl-canvas width800 height600/canvas把上面两个着色器源码和这段JS依次放进script标签页面打开就能看到深蓝色背景中央出现一个直径约80像素的红色圆点边缘平滑。把u_PointSize改成20你会看到一个指甲盖大小的小圆点改成200圆几乎占满屏幕但边缘依然保持平滑。这就是fwidth做自适应过渡带的好处。3.4 一次画多个圆点粒子系统的雏形很多读者学到这里会问画一个点有什么用实际上只要在缓冲区里放更多坐标就可以画任意数量的点。比如下面这段随机生成50个点的代码就是粒子系统的雏形const positions []; for (let i 0; i 50; i) { positions.push((Math.random() - 0.5) * 2.0); positions.push((Math.random() - 0.5) * 2.0); } gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW); gl.drawArrays(gl.POINTS, 0, 50);GPU会对每个顶点生成自己的点精灵逐一执行片元着色器。如果点之间距离小于点直径会发生重叠混合模式下重叠区域的颜色会自然叠加看起来就有“粒子相互融合”的效果。这是粒子系统最基础的形态。4. 实战中绕不开的坑与排查经验4.1 gl_PointSize有硬件上限超了会被悄悄截断我前面埋过这个伏笔gl_PointSize不是无限大的WebGL实现规定了支持的尺寸范围。想知道当前设备的具体范围可以执行let range gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE); console.log(point size range:, range[0], range[1]);常见桌面浏览器里范围往往是[1, 1024]移动端差异比较大有些设备可能只有[1, 64]甚至更小。如果你设置的gl_PointSize超过上限WebGL不会直接报错而是把值截断到最大值表现就是“明明设了很大的点屏幕上还是很小”。遇到这种情况先查一下范围不要凭直觉继续调大参数。如果上限确实很小比如只有16又想画大圆就不要用gl.POINTS模式了改用三角形扇来画后面4.5小节会展开对比。4.2 圆点画出来还是方形按这个清单排查我在帮别人调试时见过最多的问题就是“片元着色器写了为什么点还是方的”。大多数情况下问题不出在着色器而出在渲染管线的状态设置。排查顺序如下确认gl_PointSize真的传进去且大于1。默认值是1一个像素的点当然看不出圆形。确认gl_PointCoord是在片元着色器里使用而不是在顶点着色器里。有人在顶点着色器里读gl_PointCoord得到的值是未定义的编译可能通过但结果完全不对。确认是gl.drawArrays(gl.POINTS, ...)而不是gl.TRIANGLES。确认alpha计算没有被直接覆盖比如用了discard分支但忘了开启混合。确认片元着色器里声明了precision。漏写精度在多数浏览器上是编译错误但错误日志有时不够显眼容易被忽略。其中最后一条非常隐蔽着色器编译错误往往只在控制台输出一小段GLSL ERROR很多人扫一眼就略过了其实问题就在这。4.3 圆点在高DPI屏幕上变成椭圆怎么办点精灵的尺寸单位是像素所以正常情况下拉伸不会变形。真正导致“圆变椭圆”的原因多半是canvas的drawingbuffer尺寸和CSS显示尺寸不一致。比如canvas.width设为400CSS却把它拉到800px宽GPU渲染的buffer是400乘以某一高度显示时横竖缩放比例不一致圆点自然被拉成椭圆。解决办法很简单保持canvas.width、canvas.height和页面CSS里实际展示的像素比例一致。常用做法是读取canvas.getBoundingClientRect()的宽高乘以window.devicePixelRatio后写回canvas.width和canvas.height让GPU在原分辨率下渲染避免CSS拉伸。4.4 粒子一多边缘就开始闪烁在大量粒子场景里一个很常见的问题是边缘闪烁。原因是片元着色器默认用mediump精度在低端设备上mediump浮点数精度有限dist在0.5附近的小数位不够同一个像素在相邻帧计算出略微不同的alpha就会产生闪烁或“毛毛虫”边缘。解决办法是在片元着色器顶部声明precision highp float;如果设备不支持highp极少见可以把smoothstep过渡带稍微放宽一点也能缓解闪烁。另外fwidth是GPU偏导数计算少数老驱动对fwidth的支持有异常如果遇到退回固定过渡带方案float aa 0.03; float alpha 1.0 - smoothstep(0.5 - aa, 0.5 aa, dist);固定值方案在不同点大小下效果不一致属于兜底方案能用但不算最优。4.5 不想用距离公式时的替代方案如果你的圆点需要更多细节比如带纹理的图案、扇形缺口、渐变辉光单纯的距离公式会越写越复杂。这时候可以考虑两种替代方案。第一种是点精灵贴纹理。在Canvas里先画一个圆形渐变上传成纹理片元着色器里直接采样。好处是任意图案都能做坏处是要管理纹理资源和采样坐标性能略低于纯数学判断。第二种是用三角形扇逼近圆。一个圆拆成几十个三角形适合半径很大的圆比如地图上标记点的外圈。但如果粒子数量上万用trianlges画圆会让顶点数爆炸性能急剧下降。这种场景还是老老实实用gl.POINTS加片元判断。基本权衡是小元素、大量数量选点精灵大尺寸、少量数量选三角网格带复杂图案选纹理贴图。最后分享一个我在项目中经常用的小技巧。上面的抗锯齿用的是fwidth但粒子系统的视觉风格经常需要调节——想要硬朗的颗粒感还是柔和的辉光感我会留一个u_Softness系数乘在fwidth上相当于把过渡带缩放。值小于1边缘更锋利适合灰尘、沙砾值大于1边缘发虚适合光晕、能量点。但要提醒一句u_Softness调到2以上时点会明显“发胖”看起来比实际尺寸大了一圈做布局计算时要留出余量。圆形的点本身只是片元着色器思维的一个小入口。它没有复杂的几何变换却把“逐像素控制输出”这件事练得很扎实。把这一篇吃透之后再去写雪花、雨滴、光斑粒子或者把gl_PointCoord替换成自定义的二维SDF都是在同一个框架下增加数学公式而已。我强烈建议你亲手把demo跑一遍然后把点数量改成5000加上简单的速度和位移感受一下GPU批量绘制带来的流畅度。那才是这个知识点真正值钱的地方。