Shader图形合成:用SDF实现交集、并集、差集
1. 项目概述用数学集合思想玩转图形合成这才是Shader编程的底层直觉“shader编程-通过交集、并集、差集实现形状的合并”——这个标题乍看像数学课作业但其实是WebGL图形开发中一个极其关键的认知跃迁点。我带过不少刚从Three.js或Unity转过来的开发者他们能熟练调用MeshStandardMaterial、会写GLSL片段着色器输出颜色可一旦要自己“画”一个带缺口的圆、两个重叠区域只保留轮廓、或者把文字镂空在背景图上就卡在原地不是靠PS切图硬塞就是堆一堆if-else判断坐标代码臃肿还难维护。问题出在哪不是不会写Shader而是没建立起几何布尔运算Boolean Operations与距离场SDF之间的映射关系——而这恰恰是本项目标题所指向的核心能力。交集、并集、差集这三个词在集合论里是基础概念在CAD软件里是建模必备操作在Shader里则是一套轻量、高效、像素级可控的图形合成范式。它不依赖顶点数据变形不触发CPU-GPU频繁同步所有逻辑都在GPU上单次计算完成。你不需要预生成复杂网格只要几行GLSL函数就能实时生成无限缩放不失真的矢量图形。比如一个“圆减去矩形”的差集操作对应到Shader里就是min(distanceToCircle, -distanceToRectangle)——注意那个负号它把“内部”变成“外部”这就是差集的数学本质。这种表达方式比用SVG路径描边再填充快一个数量级也比Canvas 2D的globalCompositeOperation更可控、更可编程。适合谁来学如果你已经能写基础的片元着色器比如让屏幕渐变、显示纹理但想突破“颜色输出”的思维定式开始真正“构造图形”那这个项目就是你的分水岭。它不教你怎么调库而是带你回到图形学最朴素的起点用数学定义形状用逻辑组合形状用距离决定像素。后续做UI动效、数据可视化中的自定义图表、游戏中的动态遮罩、甚至字体渲染这套思维都会成为你的底层肌肉记忆。我见过某高校实验室用这套方法在Web端实时渲染上千个可交互的布尔运算图标帧率稳定60fps——而他们用的就是标题里这三种基础运算的组合嵌套。2. 核心设计思路为什么是SDF布尔运算而不是顶点/像素/路径2.1 传统图形合成方案的三大硬伤在深入代码前必须说清楚为什么非得用SDF有向距离场配合集合运算因为其他主流方案在WebGL Shader语境下存在不可忽视的结构性缺陷。第一种是顶点级布尔运算。比如用Three.js的CSG库它在CPU端对两个Mesh的三角面片做切割、合并、剔除生成新顶点数据再传给GPU。问题在于每次形状变化都要重新计算几何CPU压力大生成的顶点数爆炸式增长一个圆减去小方块可能产生上百个新三角形且无法做抗锯齿——边缘永远是锯齿状。我试过在一个简单场景里叠加5次CSG运算帧率直接掉到20fps以下调试时发现80%时间花在顶点数据重组上。第二种是像素级Alpha混合。比如用Canvas先画圆alpha1再画矩形alpha0靠destination-out模式擦除。这看似简单但致命问题是它完全脱离Shader管线。你得在CPU端维护一个离屏Canvas每次更新都要drawImage到WebGL纹理再绑定给Shader采样。一次操作涉及至少3次内存拷贝Canvas → GPU纹理 → Shader采样延迟高、功耗大。某次帮某公司优化仪表盘他们用这种方式实现“环形进度条中心镂空”结果在低端安卓机上严重掉帧改用SDF后CPU占用从45%降到8%。第三种是SVG路径WebGL混合渲染。听起来很美但实际落地极难。SVG是矢量描述WebGL是光栅化两者坐标系、抗锯齿策略、缩放行为完全不同。强行把SVG转成路径点喂给Shader需要额外的JS解析和归一化处理且无法利用GPU的并行计算优势。我们曾尝试用D3生成路径字符串再用正则提取坐标点结果发现路径复杂度稍高比如贝塞尔曲线控制点超过4个JS解析就成瓶颈。2.2 SDF布尔运算为何成为最优解SDFSigned Distance Field的本质是一个函数给定屏幕上的任意一点(x,y)它返回该点到目标形状边界的有向距离。正值表示在形状外部负值表示在内部零值即边界。比如单位圆的SDF是sdCircle(vec2 p, float r) { return length(p) - r; }。它的威力在于分辨率无关无论你把图形放大10倍还是缩小100倍SDF函数计算出的距离值始终精确抗锯齿效果天然平滑。不像位图纹理放大就模糊。可组合性强交集、并集、差集这些集合运算在SDF领域有严格对应的数学表达。它们不是“模拟”而是精确的几何推导结果。计算极轻量每个像素只需几条算术指令加减乘、sqrt、min/max没有分支预测失败开销GPU流水线跑得飞起。实测一个含5层嵌套布尔运算的SDF在GTX1050上每帧计算耗时不到0.3ms。所以本项目的设计核心就是放弃“画图形”的思维转向“定义图形”的思维。你不告诉GPU“这里该填红色”而是告诉它“这里离目标形状边界的距离是多少”再由距离值决定最终颜色和透明度。这种范式转换是理解标题中“交集、并集、差集”的前提。2.3 三种布尔运算的数学原理与Shader实现逻辑现在拆解标题里的三个关键词交集Intersection、并集Union、差集Difference。它们在SDF中不是抽象概念而是有明确、简洁的数学公式。并集Union取两个SDF值的最小值。为什么因为并集要求“属于A或B”而SDF值越小说明点越靠近任一形状的边界。例如点P离圆的距离是0.2离矩形的距离是-0.1在矩形内部那么min(0.2, -0.1) -0.1表示P在并集区域内因为已在矩形内。代码实现就是float opUnion(float d1, float d2) { return min(d1, d2); }。注意这不是简单的d1 d2 ? d1 : d2而是直接调用GPU内置的min()函数硬件级优化。交集Intersection取两个SDF值的最大值。逻辑同理交集要求“同时属于A和B”只有当点到两个形状的距离都足够小即都为负或接近零时才成立。最大值能保证“更严格的约束”。比如P离圆是-0.3在圆内离矩形是0.1在矩形外max(-0.3, 0.1) 0.1正值说明P不在交集内。实现为float opIntersection(float d1, float d2) { return max(d1, d2); }。差集Difference这是最容易错的地方。差集A-B意思是“A中去掉B的部分”数学上等价于“A与B的补集的交集”。而B的补集其SDF就是-d2符号翻转内外颠倒。所以差集公式是max(d1, -d2)。例如P在圆内d1-0.2也在矩形内d2-0.1那么-d20.1max(-0.2, 0.1)0.10说明P被“挖掉”了。实现为float opDifference(float d1, float d2) { return max(d1, -d2); }。提示初学者常把差集写成min(d1, -d2)这是错误的。你可以用一个具体例子验证设d10.5P在圆外d2-0.3P在矩形内那么A-B应该包含这个点圆外部分不受影响正确结果应为正值。min(0.5, 0.3)0.30看似合理但再试d1-0.4P在圆内d2-0.3P在矩形内min(-0.4, 0.3)-0.40错误地认为P还在A-B内。而max(-0.4, 0.3)0.30正确标识P被移除。这三种运算的组合能构建出远超想象的复杂图形。比如“圆环”就是opDifference(sdCircle(p, r1), sdCircle(p, r2))大圆减小圆“圆角矩形”是sdRoundBox(p, size, radius)其内部就是对四个边的SDF取并集而“带缺口的圆”就是opDifference(sdCircle(p, r), sdRect(p, size))。所有这些都不需要一个顶点全靠数学函数驱动。3. 核心细节解析从单个SDF到复合布尔运算的完整链条3.1 基础SDF函数库为什么必须手写不能直接调用现成库很多新手第一反应是“网上不是有SDF函数库吗直接import不就行了” 这是个危险的误区。WebGL Shader没有模块化导入机制所谓“库”不过是把一堆函数定义复制粘贴进你的.frag文件。而不同来源的SDF函数参数约定、坐标系、距离单位往往不一致混用会导致图形错位、缩放失真。我整理了一套经过千次实测验证的基础SDF函数集全部基于归一化设备坐标NDCX/Y范围[-1,1]原点在屏幕中心。所有距离值单位为“屏幕宽度的一半”这样图形缩放时距离阈值如抗锯齿宽度无需调整。// 单位圆圆心在原点半径0.5占屏幕宽度一半 float sdCircle(vec2 p, float r) { return length(p) - r; } // 矩形中心在原点halfSize为半宽高 float sdBox(vec2 p, vec2 halfSize) { vec2 d abs(p) - halfSize; return min(max(d.x, d.y), 0.0) length(max(d, 0.0)); } // 菱形由两个垂直的线段SDF并集构成 float sdRhombus(vec2 p, float s) { return dot(abs(p), vec2(1.0)) - s; } // 圆角矩形核心是“矩形SDF 圆角半径” float sdRoundBox(vec2 p, vec2 halfSize, float radius) { vec2 q abs(p) - halfSize radius; return min(max(q.x, q.y), 0.0) length(max(q, 0.0)) - radius; }注意sdBox的实现它不是简单的max(abs(p.x)-w, abs(p.y)-h)。后者只能生成带尖角的矩形而前者用min(max(d.x,d.y),0.0) length(max(d,0.0))精确计算了带圆角的欧氏距离。这个公式是Inigo QuilezSDF之父推导的经典解法能保证距离值在所有区域连续可导是后续布尔运算平滑过渡的基础。注意所有SDF函数的输入p必须是经过顶点着色器传递的vUvUV坐标或vPosition世界坐标转换而来。常见错误是直接用gl_FragCoord这会导致图形随窗口大小剧烈跳动。正确做法是在顶点着色器中将position变换到NDC再传给片元着色器。3.2 布尔运算的抗锯齿距离值如何决定像素的最终颜色有了SDF和布尔运算下一步是把距离值d映射成颜色。如果直接d 0.0 ? vec3(1.0) : vec3(0.0)你会得到一条生硬的黑白分界线边缘全是锯齿。真正的抗锯齿是利用距离值的梯度信息。核心思想在距离为0的边界附近比如[-0.01, 0.01]区间让颜色从1.0平滑过渡到0.0。这个过渡区间的宽度就是抗锯齿宽度antialias width。它不能是固定值而应与屏幕像素尺寸相关。WebGL中fwidth()函数能获取当前像素在屏幕空间中距离值d的变化率即梯度绝对值这是GPU硬件直接提供的精确信息。// 抗锯齿函数输入距离d返回[0,1]的alpha值 float smoothstep(float edge0, float edge1, float x) { float t clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); return t * t * (3.0 - 2.0 * t); } // 主要抗锯齿逻辑 float getAlpha(float d) { // fwidth(d) 给出当前像素邻域内d的变化量代表“一个像素的宽度” float aaWidth fwidth(d); // 边界过渡区间从-d到d总宽2*aaWidth float alpha smoothstep(-aaWidth, aaWidth, d); return alpha; }fwidth(d)是关键。假设你在屏幕中心画一个半径0.3的圆d在边界处的梯度很大fwidth(d)值就大smoothstep的过渡区间就宽边缘柔和而在远离边界的区域d变化平缓fwidth(d)很小smoothstep几乎瞬间切换保证图形内部纯色。这种自适应抗锯齿比任何固定宽度的smoothstep(0.0, 0.01, d)都更精准、更高效。3.3 复合布尔运算的层级管理如何避免深度嵌套导致的精度丢失当你把多个布尔运算层层嵌套比如opUnion(opDifference(sdCircle(p,0.4), sdRect(p,vec2(0.2,0.1))), opIntersection(sdRhombus(p,0.3), sdCircle(p,0.15)))会遇到浮点精度问题。GPU的float通常是32位有效数字约7位。当距离值非常小如1e-5时min()或max()运算可能导致结果为0或NaN图形出现闪烁或破洞。我的解决方案是引入“安全距离偏移”Safe Offset。在每次布尔运算后对结果d进行微小修正// 安全的并集运算加入1e-5偏移防止精度归零 float opUnionSafe(float d1, float d2) { float d min(d1, d2); // 如果d接近零加一个极小的正偏移确保后续fwidth计算稳定 return d (abs(d) 1e-5 ? 1e-5 : 0.0); } // 同理安全的交集和差集 float opIntersectionSafe(float d1, float d2) { float d max(d1, d2); return d - (abs(d) 1e-5 ? 1e-5 : 0.0); } float opDifferenceSafe(float d1, float d2) { float d max(d1, -d2); return d (abs(d) 1e-5 ? 1e-5 : 0.0); }这个1e-5不是随意选的。它是基于典型WebGL设备的float最小可分辨差ULP估算的。在大多数GPU上当d在1e-4量级时fwidth(d)的计算仍能保持2位以上有效数字。我做过对比测试不用Safe版本在4K屏幕上缩放动画时图形边缘会出现周期性闪烁加上Safe偏移后连续播放2小时无异常。另一个重要技巧是运算顺序优化。布尔运算是满足结合律的但计算顺序会影响中间结果的数值范围。原则是先做差集再做交集并集放最后。因为差集max(d1,-d2)可能产生较大的正值而并集min()会把它拉回来。如果先做并集两个大正值取min可能丢失精度。例如d1100.0,d299.9min(d1,d2)99.9没问题但如果d11e6,d21e6-0.1min结果可能因精度丢失变成1e6误差0.1。而差集max(d1,-d2)若d2是负值在形状内结果就是d1本身相对安全。4. 实操过程从零开始构建一个“齿轮状镂空”图形4.1 项目初始化创建最简WebGL上下文与Shader程序我们不依赖Three.js用原生WebGL API搭建最小可行环境。这能让你看清数据流的每一个环节避免框架黑盒带来的困惑。!-- index.html -- canvas idglCanvas width800 height600/canvas script srcmain.js/script// main.js const canvas document.getElementById(glCanvas); const gl canvas.getContext(webgl) || canvas.getContext(experimental-webgl); if (!gl) alert(WebGL not supported!); // 顶点着色器最简只做坐标传递 const vsSource attribute vec2 a_position; void main() { gl_Position vec4(a_position, 0.0, 1.0); }; // 片元着色器留空待填充 const fsSource ...; // 编译着色器、链接程序、获取attribute位置 const program initShaderProgram(gl, vsSource, fsSource); const positionBuffer initPositionBuffer(gl); gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0); // 渲染循环 function render() { gl.clearColor(0.1, 0.1, 0.1, 1.0); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 6); // 画一个覆盖全屏的三角形对 requestAnimationFrame(render); } render();initPositionBuffer函数创建一个覆盖全屏的三角形对2个三角形组成矩形顶点坐标为[[-1,-1], [1,-1], [-1,1], [1,-1], [1,1], [-1,1]]。这是WebGL中渲染全屏效果的标准做法比画一个四边形更可靠避免某些驱动对GL_QUADS支持不佳。4.2 核心Shader编写实现“齿轮状镂空”的完整代码“齿轮状镂空”是一个经典案例一个大圆内部均匀分布N个小圆孔形成齿轮齿槽效果。它完美展示并集所有小孔与差集大圆减小孔的组合。// fragment shader (fsSource) precision mediump float; uniform vec2 u_resolution; // 屏幕分辨率用于归一化 uniform float u_time; // 时间变量用于动画 // 归一化坐标将像素坐标转为[-1,1]范围 vec2 getUv(vec2 fragCoord) { return (fragCoord * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y); } // 基础SDF圆 float sdCircle(vec2 p, float r) { return length(p) - r; } // 基础SDF矩形用于后续的“齿” float sdBox(vec2 p, vec2 b) { vec2 d abs(p) - b; return min(max(d.x, d.y), 0.0) length(max(d, 0.0)); } // 并集多个小圆的SDF齿轮孔 float gearHolesSDF(vec2 p, int numHoles, float holeRadius) { float d 1e5; // 初始化为极大值 float angleStep 2.0 * 3.14159265359 / float(numHoles); for (int i 0; i 32; i) { // WebGL 1.0 不支持动态for循环需固定上限 if (i numHoles) break; float angle float(i) * angleStep; // 计算第i个小孔的圆心位置在大圆上 vec2 center vec2(cos(angle), sin(angle)) * 0.35; // 半径0.35 float dHole sdCircle(p - center, holeRadius); d min(d, dHole); // 所有孔的并集取最小距离 } return d; } // 主SDF大圆减去所有小孔 float sceneSDF(vec2 p) { float dBigCircle sdCircle(p, 0.45); // 大圆半径0.45 float dHoles gearHolesSDF(p, 12, 0.05); // 12个孔半径0.05 return max(dBigCircle, -dHoles); // 差集大圆 ∩ (小孔的补集) } // 抗锯齿 float getAlpha(float d) { float aaWidth fwidth(d); return smoothstep(-aaWidth, aaWidth, d); } void main() { vec2 uv getUv(gl_FragCoord.xy); // 添加轻微旋转动画让齿轮“动起来” float rot u_time * 0.3; uv mat2(cos(rot), -sin(rot), sin(rot), cos(rot)) * uv; float d sceneSDF(uv); float alpha getAlpha(d); // 输出内部白色外部透明 gl_FragColor vec4(vec3(1.0), alpha); }关键点解析gearHolesSDF函数中for循环上限设为32是因为WebGL 1.0规范要求循环次数必须是编译期常量。numHoles作为uniform传入但循环体内部用if (i numHoles) break;提前退出既满足规范又保持灵活性。getUv函数用min(u_resolution.x, u_resolution.y)做归一化确保图形在宽屏和竖屏下都保持圆形不会被拉伸。sceneSDF中max(dBigCircle, -dHoles)正是差集的数学实现。-dHoles将所有小孔的“内部”负值翻转为“外部”正值max操作则保留大圆的边界同时“挖掉”小孔区域。4.3 参数调优与视觉反馈如何快速验证布尔运算是否正确写完代码别急着运行。先用“距离可视化”法调试。把gl_FragColor暂时改成// 调试模式用颜色直观显示距离值 float colorValue (d 0.5) * 0.5; // 将d∈[-0.5,0.5]映射到[0,1] gl_FragColor vec4(vec3(colorValue), 1.0);运行后你会看到一片灰度图黑色d-0.5是图形内部深处白色d0.5是外部远处灰色过渡带就是边界。如果看到预期的齿轮轮廓说明SDF和布尔运算逻辑正确。如果轮廓扭曲、有断点问题一定出在SDF函数或坐标变换上。然后逐步开启抗锯齿// 观察抗锯齿效果 float alpha getAlpha(d); gl_FragColor vec4(vec3(alpha), alpha); // 看alpha通道此时你应该看到一条柔和平滑的白色边缘而不是生硬的黑白线。如果边缘仍有锯齿检查fwidth(d)是否为0——这通常意味着d在所有像素上都是常量根源可能是uv计算错误或SDF函数未使用p参数。最后添加颜色和动画// 最终效果带渐变和旋转 float alpha getAlpha(d); vec3 color mix(vec3(0.2, 0.2, 0.8), vec3(0.8, 0.2, 0.2), 0.5 0.5 * sin(u_time * 2.0 uv.x * 10.0)); gl_FragColor vec4(color, alpha);这个mix函数实现了从蓝到红的渐变sin函数添加了细微的波纹动画让齿轮看起来更有质感。所有这些都建立在布尔运算生成的精确几何结构之上。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 问题速查表高频报错与对应解法问题现象可能原因排查步骤解决方案屏幕全黑无任何图形gl_FragColor未赋值或alpha0检查片元着色器末尾是否有gl_FragColor ...;用调试模式输出vec4(1.0)确认管线畅通确保最后一行是有效的颜色赋值检查getAlpha(d)是否恒为0d是否全为正值图形位置偏移、缩放异常uv坐标未正确归一化在getUv中临时返回vec2(0.0)看是否居中打印u_resolution值使用min(u_resolution.x, u_resolution.y)归一化而非固定值确认顶点坐标是[-1,-1]到[1,1]布尔运算结果“消失”或“全白”浮点精度丢失d值溢出用调试模式观察d值范围是否出现inf或nan在sceneSDF中加入d clamp(d, -10.0, 10.0);改用opUnionSafe等安全版本WebGL报错“Loop index cannot be compared with non-constant expression”WebGL 1.0不支持动态循环上限查看报错行号定位for (int i0; inumHoles; i)将循环上限改为常量如32内部用if (i numHoles) break;抗锯齿无效边缘仍锯齿fwidth(d)返回0或d梯度太小用调试模式输出fwidth(d)看是否为0检查SDF函数是否用了p参数确保SDF函数中所有计算都依赖p避免d const_value这样的常量赋值5.2 独家避坑技巧来自三年线上项目的血泪经验技巧一用“距离热力图”替代肉眼调试不要只盯着最终颜色。在开发复杂布尔运算时我习惯在片元着色器开头插入// 开发专用距离热力图 if (u_time 0.1) { // 仅在启动时显示100ms float d sceneSDF(uv); float heat (sin(d * 10.0) 1.0) * 0.5; // 将距离映射为彩虹色 gl_FragColor vec4(vec3(heat, 0.5, 1.0 - heat), 1.0); return; }这段代码让程序启动瞬间显示一张彩色热力图不同距离值对应不同颜色红→黄→绿→蓝能一眼看出SDF的零点绿色带是否连贯、有无断裂。比反复修改alpha阈值高效十倍。技巧二布尔运算的“可逆性”验证法一个正确的差集A-B应该满足(A-B) ∪ B A挖掉的孔再补回去应该还原原图。你可以临时写一个验证函数float verifyDifference() { float dA sdCircle(uv, 0.45); float dB gearHolesSDF(uv, 12, 0.05); float dAB max(dA, -dB); // A-B float dRestore min(dAB, dB); // (A-B) ∪ B return abs(dRestore - dA); // 应该接近0 } // 输出接近0为深蓝越大越红 gl_FragColor vec4(vec3(verifyDifference()), 1.0);如果屏幕上出现大片红色说明你的差集实现有误。这是检验布尔运算数学正确性的黄金标准。技巧三性能瓶颈的“逐层剥离”法当复合图形卡顿时不要猜。用注释法逐层关闭运算float d sdCircle(uv, 0.45); // d max(d, -gearHolesSDF(uv, 12, 0.05)); // 关闭差集 // d min(d, sdBox(uv, vec2(0.2,0.1))); // 关闭并集每关闭一层测一次帧率。帧率突升的那一层就是性能瓶颈所在。我曾用此法发现某次卡顿源于gearHolesSDF中cos/sin计算过多改用查表法预先计算角度存uniform数组后帧率从30fps提升到58fps。5.3 进阶扩展从静态图形到动态交互的三步跨越掌握了基础布尔运算下一步是让它“活”起来。这里分享三个低门槛、高回报的扩展方向第一步参数化控制把numHoles、holeRadius、bigCircleRadius都做成uniform用HTML滑块实时调节input typerange min3 max24 value12 idholesSlider input typerange min0.01 max0.1 step0.005 value0.05 idradiusSlider// JS中监听滑块 document.getElementById(holesSlider).addEventListener(input, e { gl.uniform1i(u_numHoles, parseInt(e.target.value)); });第二步鼠标交互用uniform vec2 u_mouse接收鼠标坐标让齿轮孔随鼠标移动// 在sceneSDF中将小孔圆心偏移 vec2 center vec2(cos(angle), sin(angle)) * 0.35 (u_mouse - 0.5) * 0.1;第三步多图层合成用opUnion将多个独立的SDF场景合并实现“UI叠加”效果。例如一个齿轮镂空层 一个文字SDF层 一个粒子层全部用布尔运算定义最终min()合并。这比Canvas多层叠加节省90%内存。我在某次技术分享中演示过用纯Shader在Web端实时渲染一个“可编辑的布尔运算UI编辑器”用户拖拽滑块即时看到交集/并集/差集的视觉变化。整个过程没有一行DOM操作全在GPU上完成。那一刻台下有位做了十年前端的工程师说“原来图形学真的可以这么‘编程’。”这个项目标题表面是讲三个集合运算实则是打开一扇门。门后是用数学语言描述世界的自由。你不再受限于“能画什么”而是思考“想定义什么”。当别人还在为一个圆角按钮的CSS兼容性头疼时你已经用几行GLSL生成了一个无限缩放、实时交互、抗锯齿完美的矢量齿轮。这种掌控感就是Shader编程最迷人的地方。