1. 从方正的点讲起光栅化规则与两个关键内置变量做粒子效果的时候我第一次用gl.drawArrays(gl.POINTS, 0, 10000)往屏幕上撒了一万个点结果屏幕上全是密密麻麻的小方块。当时我以为是自己数据传错了检查半天发现纹理、缓冲区、着色器都没问题——查资料才知道WebGL里点图元默认就是方的想画圆形点得自己在片元着色器里“切”一刀。这个现象背后是图形管线的光栅化规则。你在顶点着色器里设置了gl_Position和gl_PointSizegl_Position告诉 GPU 这个点落在屏幕哪个位置gl_PointSize告诉 GPU 这个点占据多大区域。光栅化阶段GPU 把这个点当成一个以gl_Position为中心的方块来处理方块边长就是gl_PointSize对应的像素数。举个直观的例子gl_PointSize 10.0那光栅化器就会生成一个 10×10 的像素区域这个区域内的每个片元都会执行一次片元着色器。你没写任何特殊逻辑时片元着色器给什么颜色方块就显示什么颜色于是默认效果就是纯色方块。这里有两个内置变量必须理解透因为它们就是这次“修方为圆”的全部工具变量所在着色器含义gl_PointSize顶点着色器点的大小像素只能在这里赋值gl_PointCoord片元着色器当前片元在点区域内的归一化坐标范围 [0,0] 到 [1,1]gl_PointCoord是关键。GPU 在光栅化这个方块区域时会为每个片元计算一个落在 [0,0] 到 [1,1] 范围内的坐标可以把它理解为这个片元在“点区域”里的 UV 坐标左下角是 (0,0)右上角是 (1,1)正中心是 (0.5,0.5)。注意这个坐标是 GPU 自动算好传入片元着色器里的不需要你手动声明 attribute直接用就行。有了gl_PointCoord思路就通了计算出当前片元到中心 (0.5,0.5) 的距离如果距离大于圆的半径就把这个片元丢弃discard剩下的片元自然就是一个圆形区域。这套逻辑属于图形学里非常基础的程序化形状生成思路核心就一句话用距离做判断决定哪些片元保留、哪些片元丢弃。我还见过不少初学者在这步把gl_PointSize当成 uniform 传给片元着色器想在片元里自己算半径其实完全没必要。gl_PointCoord已经做了归一化圆心永远是 (0.5,0.5)边长永远是 1半径永远是 0.5不需要关心点实际占了多少像素。归一化坐标是这个方案最省心的地方。2. 距离场思想用gl_PointCoord把方点切成圆点先看完整代码再逐行拆。顶点着色器保持最简配置// 顶点着色器 attribute vec4 a_Position; uniform float u_PointSize; void main() { gl_Position a_Position; gl_PointSize u_PointSize; }片元着色器是这次的主角precision mediump float; void main() { // 计算当前片元到点中心的距离 float d distance(gl_PointCoord, vec2(0.5, 0.5)); // 距离超过半径丢弃片元 if (d 0.5) { discard; } gl_FragColor vec4(1.0, 0.5, 0.2, 1.0); }distance(gl_PointCoord, vec2(0.5, 0.5))算的是归一化坐标里的欧氏距离。因为整个点区域边长为 1圆心在 (0.5,0.5)所以圆的半径最大只能是 0.5正好可以内切于方块区域。当d 0.5时片元落在圆外直接discard掉GPU 不会把这个片元写入颜色缓冲区方块的四角就空出来了。渲染时JavaScript 侧只需要准备一个点的坐标设置好u_PointSize调用一次绘制const gl canvas.getContext(webgl); // ...编译链接 shader、绑定 buffer 等常规步骤略 gl.uniform1f(u_PointSize, 30.0); gl.drawArrays(gl.POINTS, 0, 1);drawArrays(gl.POINTS, 0, 1)表示从第 0 个顶点开始按点图元方式绘制 1 个顶点。WebGL 对点图元的处理是一个顶点对应一个点不是三个顶点一个三角形那种聚簇方式。想画 100 个圆点传 100 个顶点进去每个顶点的gl_PointSize也可以各自不同。实际项目里我更推荐写紧凑一点的等价形式省一次开方运算。GPU 虽然硬算distance也没问题但粒子数量上了万级以后这点开销能省则省precision mediump float; void main() { vec2 uv gl_PointCoord - vec2(0.5, 0.5); float d2 dot(uv, uv); // 距离平方 if (d2 0.25) { // 0.5 的平方是 0.25 discard; } gl_FragColor vec4(1.0, 0.5, 0.2, 1.0); }dot(uv, uv)算的是向量与自身的点积结果就是距离平方阈值从 0.5 变成 0.25。这在数学上完全等价删掉了sqrt计算。到这里你可能会想既然只是在片元里判断“圆内保留、圆外丢弃”那是不是可以推广到任意形状没错这就是距离场distance field的雏形。每个片元都拥有一个“到中心/到边界”的距离值配合不同的阈值、比较操作、数学函数就能程序化生成圆、环、方角矩形、光晕、条纹等各种形态。第 4 章我会展开三种最常见的变体这块先留个印象。3. 边缘处理discard直切与smoothstep抗锯齿哪个更适合你上一章的写法能跑但把点放大到 100 像素以上边缘会出现明显的锯齿圆轮廓线像狗啃过一样。原因很直接discard是硬切换片元要么完全保留、要么完全丢弃在圆的边界处一个像素可能恰好有一半在圆内、一半在圆外但最终结果只有两种状态没有中间过渡于是呈现锯齿。修锯齿的思路也很直白不搞非黑即白的discard改成“边缘处透明度渐变”。离圆心越近越不透明越远越透明让颜色在边界处平滑衰减。GLSL 里做这种渐变首选smoothstep。precision mediump float; void main() { vec2 uv gl_PointCoord - vec2(0.5, 0.5); float d length(uv); // 从 0.45 到 0.5 之间平滑过渡 float alpha 1.0 - smoothstep(0.45, 0.5, d); if (alpha 0.01) { discard; } gl_FragColor vec4(1.0, 0.5, 0.2, alpha); }smoothstep(0.45, 0.5, d)的意思是当d 0.45时返回 0d 0.5时返回 1中间区域做平滑插值。用1.0减去它相当于让边缘 0.05 的范围内透明度从 1 渐变到 0。这 0.05 的宽度是归一化值实际对应多少像素取决于点的大小。点大、过渡带就宽点小、过渡带就窄。想调边缘锐利程度改这两个数值即可比如smoothstep(0.48, 0.5, d)边缘更利落smoothstep(0.4, 0.5, d)边缘更柔和。注意光有 alpha 还不够JavaScript 侧必须开启混合否则透明部分会直接覆盖背景色看起来像加了黑边或花边gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);SRC_ALPHA, ONE_MINUS_SRC_ALPHA是标准透明混合源颜色的 alpha 作为权重混合当前片元和背景色。粒子系统里基本都会开这一行很多人漏掉结果圆形边缘出现黑色方块残留排查半天没想到是混合没开。两种方案的取舍我做了个对比表实际选型时可以直接参考方案边缘效果性能适用场景discard硬切锐利放大后有锯齿不产生混合开销但增加片元剔除小尺寸点、风格化像素效果smoothstep渐变平滑边缘自然需要开启混合多一次混合计算粒子系统、光晕、发光效果我个人的习惯是点尺寸小于 20 像素时直接discard看不出明显锯齿点尺寸大或数量多且重叠加时用smoothstep加混合。另外提一嘴WebGL 的 canvas 上下文本身有antialias属性但它主要影响几何图元的边缘对gl.POINTS的块状光栅化基本没有帮助别指望靠它解决圆形点锯齿。4. 圆环点、光晕点、圆角方点一套距离场的三种玩法学会了“距离判断 透明度控制”其实已经拿到了绘制任意程序化点样式的大门钥匙。这一章分享三种我在实际项目里用过的变体代码都基于同一个框架先算一个距离值再按需求做变换。4.1 圆环点内外双阈值圆环和实心圆唯一的区别是保留的不是d 0.5的圆盘而是0.4 d 0.5的环带。片元里用两个smoothstep做差即可precision mediump float; void main() { vec2 uv gl_PointCoord - vec2(0.5, 0.5); float d length(uv); // 外边缘 float outer smoothstep(0.42, 0.5, d); // 内边缘 float inner smoothstep(0.28, 0.35, d); float alpha outer - inner; if (alpha 0.01) { discard; } gl_FragColor vec4(0.3, 0.8, 1.0, alpha); }outer - inner的结果是在环带区域内 alpha 趋近 1在环带内外都趋近 0。也可以直接写成abs(d - 0.43) 0.035这种距离差形式但锐利边缘较多不如双 smoothstep 好看。这种点常用于游戏里的目标标记、技能指示圈、雷达小点。4.2 光晕点指数衰减代替阈值判断光晕和实心圆的最大区别是不需要discard来切边缘而是让透明度随着距离增加连续衰减。用指数函数最顺手precision mediump float; void main() { vec2 uv gl_PointCoord - vec2(0.5, 0.5); float d length(uv); // d 范围是 0~0.707先归一化到 0~1再衰减 float t d * 1.414; float glow pow(1.0 - clamp(t, 0.0, 1.0), 3.0); if (glow 0.01) { discard; } gl_FragColor vec4(0.6, 0.8, 1.0, glow); }pow(1.0 - t, 3.0)让衰减曲线带一点“中间亮、边缘暗”的感觉幂次越大光晕越聚拢在中心。如果你想让中心有个更亮的核可以把衰减函数拆成两项比如中心核用pow(1.0 - t, 6.0)外围光晕用pow(1.0 - t, 2.0) * 0.5然后相加这属于调参手感活没有绝对标准。光晕点用来做粒子爆炸、星光、环境尘埃特别合适因为这个方案天然半透明多个点叠加时会产生非常自然的亮度堆叠。4.3 圆角方点切比雪夫距离取了半天欧氏距离换个思路用横纵坐标的最大值。max(abs(uv.x), abs(uv.y))得到的是切比雪夫距离等距离线是方形配合一个小阈值切角就能得到圆角方形点precision mediump float; void main() { vec2 uv gl_PointCoord - vec2(0.5, 0.5); float d max(abs(uv.x), abs(uv.y)); float alpha 1.0 - smoothstep(0.42, 0.5, d); if (alpha 0.01) { discard; } gl_FragColor vec4(0.9, 0.4, 0.6, alpha); }这个形状在 UI 界面、像素风游戏、复古 HUD 里很常见。想控制圆角弧度可以再加一层距离场运算对向量做length(max(abs(uv) - vec2(0.3), 0.0))这种 SDF 圆角矩形套路但用smoothstep调过渡带的观感差别不大多数场景够用。三个变体背后的共性可以提炼成一句话先构造距离值再用阈值、差值或函数变换映射成 alpha 和颜色。这个思路熟练之后你看到任何“点状图形”基本都能在脑子里拆出它的距离函数长什么样。我整理了快速对照表形状距离函数保留条件/透明度实心圆length(uv)d 0.5或1-smoothstep圆环length(uv)双阈值区间outer - inner光晕length(uv)pow(1-d, n)连续衰减圆角方点max(abs(uv.x), abs(uv.y))smoothstep边缘过渡5. 移动端与兼容性点大小限制和几个容易翻车的细节圆形点写完之后换到手机上看效果可能发现点变小了、甚至直接消失或者形状变成方块、圆角出现怪异的锯齿。这些大概率不是着色器逻辑问题而是平台对gl_PointSize的限制和精度差异。5.1 先查你设备支持的最大点大小OpenGL ES 2.0 标准只保证gl_PointSize至少支持到 1.0不保证上限。桌面显卡通常支持很大比如 255 甚至 1024但移动 GPU 上限差别很大有的 64有的 128部分低端设备甚至只有 31。你写死 100 像素超出上限后 GPU 会按实现自行处理有的 clamp、有的裁掉、有的直接不绘制。运行前一定要查实际值const range gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE); console.log(Point size range:, range); // 例如 Float32Array [1, 255.0]拿到上限后把u_PointSize限制在range[1]以内或者干脆在片元里做缩放适配不要在顶点着色器里传超限值。我这里说的是 WebGL1WebGL2 对点大小限制同样存在别以为换了版本就万事大吉。5.2 小尺寸点的像素覆盖问题当gl_PointSize小于 1 时GPU 光栅化会用一个近似方式把点覆盖到最近的像素上稍微偏移就可能导致点闪烁或消失。做粒子系统时如果你想让粒子“从无到有”地出现建议不要直接改gl_PointSize到 0.1而是保持点大小固定用 alpha 的渐变来控制显隐。这也是为什么我前面反复强调 alpha 渐变方案的实用价值它不只是为了抗锯齿更是为了规避小点问题。另外高 DPI 屏比如手机的二倍屏、三倍屏上一个 CSS 像素对应多个物理像素。你以为画了 10px 的点实际光栅化可能覆盖 20 个物理像素圆形边缘反而容易糊。解决办法是结合window.devicePixelRatio调整 canvas 的物理尺寸和gl_PointSize保证视觉大小一致。5.3 gl_PointCoord 的精度隐患片元着色器的精度声明用的是precision mediump float在部分移动端 GPU 上mediump 浮点数只有约 10 位精度。当点很小比如 8 像素时gl_PointCoord的量化误差会被放大圆形边缘出现肉眼可见的阶梯。如果对点质量有要求可以改成precision highp float;但 highp 在某些古老设备上不被片元着色器支持稳妥做法是先检查兼容性再决定或者用中等精度但把圆半径计算稍微放大一点比如阈值用 0.49 而不是 0.5给误差留一点余量。5.4 不开启混合时透明边缘等于黑边这是被问得最多的问题明明写了 alpha边缘却是黑色或白色。原因基本是没开gl.enable(gl.BLEND)或者 canvas 的 premultipliedAlpha 设置和着色器输出不匹配。默认 Canvas 是 premultiplied alpha 模式如果你在片元着色器直接输出vec4(r, g, b, a)没有做预乘边缘处a接近 0但r、g、b可能还是原始亮度值预乘后产生偏亮的描边。最简单的处理方案是用非预乘上下文const gl canvas.getContext(webgl, { premultipliedAlpha: false });输出非预乘的vec4(r, g, b, a)混合时注意颜色即可。如果你确实要在预乘模式工作片元着色器输出应该是gl_FragColor vec4(color.rgb * alpha, alpha);。5.5 调试这类问题的小工具习惯我自己排查点渲染问题时会先在片元着色器里写一个“诊断模式”用gl_PointCoord.x和gl_PointCoord.y直接作为颜色输出比如gl_FragColor vec4(gl_PointCoord.x, gl_PointCoord.y, 0.0, 1.0)。这样屏幕上每个点会显示一个从红到绿渐变的方向盘色块一眼能看出 UV 方向和点区域范围。如果看到的是单色方块说明gl_PointCoord根本没生效如果渐变色方向反了说明数据坐标需要翻转。诊断完再换回正式片元逻辑能节省大量调试时间。另一个习惯是绘制点之前先调gl.clearColor(0, 0, 0, 0)并清除颜色缓冲排除背景残留导致视觉误判。把这两层——归一化坐标距离判断、透明度渐变处理边界——掌握之后圆形点就算真正在 WebGL 里“吃透”了。它虽然只是入门系列里的一个小节点但衍生出来的距离场思路在后面做粒子雨、星图、光点河流时会频繁用到。我每次写这类点效果基本上是拿这篇文章里的片元着色器做模板改距离函数和衰减参数适应不同视觉需求。
阅读完成 · 觉得有帮助?