几何图形
通过前面两节我们已经知道距离场构图法的关键就是要构造距离场,并学习了如何构建圆、直线、线段的距离场,以及如果通过采样来构建连续函数曲线的距离场。
接下来,我们来讨论如何构建三角形、正多边形和椭圆的距离场。
首先是三角形:
我们定义点到三角形的距离为点到三角形三条边距离中最短的一条边的距离。
根据上面的定义,在三角形内部,点到三角形的距离不会超过三角形内接圆半径l,所以我们可以通过将距离除以半径l,得到归一化的距离场,如下图所示:
还有一个关键是需要确定点在三角形内部还是外部,这一点,我们可以通过距离的符号来判断,这是因为:
如果点P在三角形内部,对于P点的观察者来说,三角形的三条边的向量矩方向(或者说三条边的旋转方向)一致,反之,如果P点在三角形外部,三条边向量矩方向不一致,这一点表现在三个距离的正负号上,如果P在三角形内部,三个距离的正负号情况一致,否则不一致。这个判断方法不仅对三角形有效,对所有凸多边形都有效。
因此我们得到三角形距离场函数:
float sdf_line(vec2 st, vec2 a, vec2 b) {
vec2 ap = st - a;
vec2 ab = b - a;
return ((ap.x * ab.y) - (ab.x * ap.y)) / length(ab);
}
float sdf_seg(vec2 st, vec2 a, vec2 b) {
vec2 ap = st - a;
vec2 ab = b - a;
vec2 bp = st - b;
float l = length(ab);
float proj = dot(ap, ab) / l;
if(proj >= 0.0 && proj <= l) {
return sdf_line(st, a, b);
}
return min(length(ap), length(bp));
}
/**
三角形的 SDF
*/
float sdf_triangle(vec2 st, vec2 a, vec2 b, vec2 c) {
vec2 va = a - b;
vec2 vb = b - c;
vec2 vc = c - a;
float d1 = sdf_line(st, a, b);
float d2 = sdf_line(st, b, c);
float d3 = sdf_line(st, c, a);
// 三角形内切圆半径
float l = abs(va.x * vb.y - va.y * vb.x) / (length(va) + length(vb) + length(vc));
// 点在三角形内部,定义距离为正
if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
return min(abs(d1), min(abs(d2), abs(d3))) / l;
}
// 点在三角形外部,定义距离为负
d1 = sdf_seg(st, a, b);
d2 = sdf_seg(st, b, c);
d3 = sdf_seg(st, c, a);
return -min(abs(d1), min(abs(d2), abs(d3))) / l;
}
复制代码
注意:我们这里对上一节的函数做了一点小调整,主要是更改了参数次序。因为有许多不同图形的距离场函数,它们的参数不同,但至少都接受st,所以我们将st统一作为距离场函数的第一个参数。
最终我们可以通过这个距离场函数,给定三个顶点坐标,绘制出三角形了。
接着是正多边形
我们可以定义点P到正多边形距离为P到正多边形最近一条边的距离,并将它除以圆心到边的距离l进行归一化。
具体的代码实现如下:
#ifndef PI
#define PI 3.141592653589793
#endif
#ifndef FLT_EPSILON
#define FLT_EPSILON 0.000001
#endif
vec2 transform(vec2 v0, mat3 matrix) {
return vec2(matrix * vec3(v0, 1.0));
}
vec2 rotate(vec2 v0, vec2 origin, float ang) {
float sinA = sin(ang);
float cosA = cos(ang);
mat3 m = mat3(cosA, -sinA, 0, sinA, cosA, 0, 0, 0, 1);
return transform(v0 - origin, m) + origin;
}
vec2 rotate(vec2 v0, float ang) {
return rotate(v0, vec2(0.0), ang);
}
float atan2(float dy, float dx) {
float ax = abs(dx);
float ay = abs(dy);
float a = min(ax, ay) / (max(ax, ay) + FLT_EPSILON);
float s = a * a;
float r = ((-0.0464964749 * s + 0.15931422) * s - 0.327622764) * s * a + a;
if(ay > ax) r = 1.57079637 - r;
if(dx < 0.0) r = PI - r;
if(dy < 0.0) r = -r;
return r;
}
float atan2(vec2 v) {
return atan2(v.y, v.x);
}
/**
从 v1 相对 v2 的逆时针夹角, 0 ~ 2 * PI
*/
float angle(vec2 v1, vec2 v2) {
float ang = atan2(v1) - atan2(v2);
if(ang < 0.0) ang += 2.0 * PI;
return ang;
}
/**
正多边形
*/
float regular_polygon(vec2 st, vec2 center, float r, float rotation, const int edges) {
vec2 p = st - center;
vec2 v0 = vec2(0, r); // 第一个顶点
v0 = rotate(v0, -rotation);
float a = 2.0 * PI / float(edges); // 每条边与中心点的夹角
float ang = angle(v0, p); // 取夹角
ang = floor(ang / a); // 在哪个区间
vec2 v1 = rotate(v0, a * ang); // 左顶点
vec2 v2 = rotate(v0, a * (ang + 1.0)); // 右顶点
float c_a = cos(0.5 * a);
float l = r * c_a;
float d = sdf_line(p, v1, v2);
return d / l;
}
复制代码
注意这里面有些细节,我们通过反正切来求向量夹角,通过线性变换rotate来旋转向量,其中线性变换是非常重要的数学方法,在后续章节中会进一步详细介绍。
这样我们就可以绘制正多边形了,比如下面的代码绘制了一个正7边形。
椭圆和椭扇形
和圆类似,我们通过椭圆方程来定义椭圆的距离场。
然后我们通过夹角来近似计算椭扇形距离场,这里我们把距离场分成三个部分,两个向量夹角中间的部分,以及左侧、右侧两边的部分。
具体实现代码如下,有很多细节,这里不再赘述,有兴趣的同学可以仔细研究,在码上掘金上修改一些参数运行看看。
float sdf_ellipse(vec2 st, vec2 c, float a, float b) {
vec2 p = st - c;
return 1.0 - sqrt(pow(p.x / a, 2.0) + pow(p.y / b, 2.0));
}
float sdf_ellipse(vec2 st, vec2 c, float a, float b, float sAng, float eAng) {
vec2 ua = vec2(cos(sAng), sin(sAng));
vec2 ub = vec2(cos(eAng), sin(eAng));
float d1 = sdf_line(st, c, ua + c);
float d2 = sdf_line(st, c, ub + c);
float d3 = sdf_ellipse(st, c, a, b);
float r = min(a, b);
vec2 v = st - c;
float ang = angle(v, vec2(1.0, 0));
if(eAng - sAng > 2.0 * PI) {
return d3;
}
if(ang >= sAng && ang <= eAng) { // 两个向量夹角中间的部分
float m = max(a, b);
float d11 = sdf_seg(st, c, ua * m + c);
float d12 = sdf_seg(st, c, ub * m + c);
if(d3 >= 0.0) {
return min(abs(d11 / r), min(abs(d12 / r), d3));
}
return d3;
}
float pa = dot(ua, v); // 求投影
float pb = dot(ub, v);
if(pa < 0.0 && pb < 0.0) {
return -length(st - c) / r;
}
if(d1 > 0.0 && pa >= 0.0) {
vec2 va = pa * ua;
float da = pow(va.x / a, 2.0) + pow(va.y / b, 2.0);
if(d3 > 0.0 || da <= pow(1.0 + abs(d1 / r), 2.0)) {
return -abs(d1 / r);
} else {
return d3;
}
}
if(d2 < 0.0 && pb >= 0.0) {
vec2 vb = pb * ub;
float db = pow(vb.x / a, 2.0) + pow(vb.y / b, 2.0);
if(d3 >= 0.0 || db <= pow(1.0 + abs(d2 / r), 2.0)) {
return -abs(d2 / r);
} else {
return d3;
}
}
}
复制代码
重复
如果要在画布上绘制多个相同图形,不必一一绘制每一个图形,要我们有一些数学手段可以运用。
简单来说,我们可以扩大st或d的值,然后对它取小数部分,比如下面的代码绘制多条直线:
void main() {
vec2 st = gl_FragCoord.xy / resolution;
float d = sdf_line(st, vec2(0), vec2(0.5));
d = abs(d);
d = fract(10.0 * d);
FragColor.rgb = stroke(d, 0.5, 0.2, 0.3) * vec3(1.0);
FragColor.a = 1.0;
}
复制代码
类似地,我们绘制多重菱形:
void main() {
vec2 st = gl_FragCoord.xy / resolution;
float d = regular_polygon(st, vec2(0.5), 0.5, 0.0, 4);
d = abs(d);
d = fract(10.0 * d);
FragColor.rgb = stroke(d, 0.5, 0.5, 0.5) * vec3(1.0);
FragColor.a = 1.0;
}
复制代码
如果我们扩大的是st而非d,得到的是另一种重复:
void main() {
vec2 st = gl_FragCoord.xy / resolution;
st = mix(vec2(-5.0), vec2(5.0), st);
float d = regular_polygon(fract(st), vec2(0.5), 0.55, 0.0, 4);
d = abs(d);
FragColor.rgb = stroke(d, 0.5, 0.5, 0.5) * vec3(1.0);
FragColor.a = 1.0;
}
复制代码
根据这个原理,我们可以绘制出类似中国传统纹饰的图案,例如:
我们看到,利用距离场加上重复,我们可以用非常简单的代码绘制出看起来比较复杂的规律图案,这个过程需要一些数学知识和想象力,但创造图案是非常有趣的,到这一步,我们的WebGL渲染渐入佳境,在后续还有更多令人惊叹的效果可以通过寥寥几行代码实现,而这正是GPU绘图的魅力!