Metaballs

发布于 大漠

Metaballs是有机的黏糊糊的黏糊糊的东西。从数学的角度来看,它们是一个等值面。可以用一个数学公式来表示:f(x,y,z) = r / ((x - x0)2 + (y - y0)2 + (z - z0)2)。@Jamie Wong写了一篇非常优秀的教程,介绍了怎么使用Canvas来渲染Metaballs。

我们可以在一个元素中使用模糊和滤镜在CSS和SVG中复制Metaball效果。比如@chris Gannon写的一个泡泡滑块的效果:

SVG Metaball

我发现了另一种方法,使用Paper.js可以实现这种效果。在编写代码的时代,@Hiroyuki Sato通过一个脚本和Adobe Illustrator生成一个Gooey Blobs的效果。与以前的技术不同的是,这并没有像素的渲染或依赖于过滤器特性。相反,它将两个圆与个膜(membrane)相连。也就是说,我们可以将整个块作为路径生成。比如@Amoebal在Codepen上写的这个示例,就采用了这种技术。

在这篇文章中,我将分解Metaball效果实现所需要的步骤。我将通过一个叫做metaball的函数来生成下面所看到的黑色阴影路径。这包括连接器加上第二个圆的一部分。

创建Metaball

要想弄清楚连接器触到两个圆的位置,我们先定位两个接触圆的切线。这是连接器的最宽处。顺便说一下,当圆圈没有重叠时,就集中在这个例子上:

我们可以使用Spread计算出最大的角度:

const maxSpread = Math.acos((radius1 - radius2) / d);

为什么是这样呢?我花了一段时间才弄明白。我试着解释一下,但是你可能看到这个外部切线的分步说明,会更易理解。

max-spread

这是连接器可以拥有的最大可能的扩展。我们可以通过将其与一个叫做v的因子相乘来控制传播量。JavaScript的代码是v=0.5。这样似乎更有效。

小圆的传播(Spread)是(Math.PI - maxSpread) * v。这主要是因为一个多边形的对角的和总是180°

接下来咱们需要找到这四个点的位置。我们知道圆的中心(center1center2)和半径(radius1radius2)。因此,我们只需要处理角度,然后使用极坐标将其转换为(x,y)值。

const angleBetweenCenters = angle(center2, center1);
const maxSpread = Math.acos((radius1 - radius2) / d);

// 圆1(左)
const angle1 = angleBetweenCenters + maxSpread * v;
const angle2 = angleBetweenCenters - maxSpread * v;

// 圆2(右)
const angle3 = angleBetweenCenters + (Math.PI - (Math.PI - maxSpread) * v);
const angle4 = angleBetweenCenters - (Math.PI - (Math.PI - maxSpread) * v);

角度需要顺时针测量。因此,对于第二个圆圈,需要把它从Math.PI中减去。我们添加了angleBetweenCenters,然后将极坐标转换为笛卡尔坐标。

// 点
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);

要将梯形形状的连接器转换成弯曲的连接器,需要将手柄添加到所有的四个点上。这个过程的下一部分是计算手柄的位置。

一个特定的句柄应该对齐到那个点上的圆切七。再次使用极坐标来定位手柄。但这一次,它将与这一点本身有关。

A B C angle 1

ABBC是垂直的,因为AB是一个圆的半径,而BC是该圆的切线。因此handle1的角度是angle1 - Math.PI / 2。同样,我们可以计算出其他三个手柄的角度值。

把手的长度是相对于圆的半径而言的。例如,handle1的长度是radius1 * d2。现在我们可以这样计算手柄的位置。

const totalRadius = radius1 + radius2;

// 处理手柄长度的因子
const d2 = Math.min(v * handleSize, dist(p1, p3) / totalRadius);

// 手柄长度
const r1 = radius1 * d2;
const r2 = radius2 * d2;

const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);

我们拥有根建SVG的path的所有点。path由三部分组成:从point1point3的曲线,从point3point4的弧线和point4point2的曲线。

function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
    return [
        'M', p1,
        'C', h1, h3, p3,
        'A', r, r, 0, escaped ? 1 : 0, 0, p4,
        'C', h4, h3, p4,
    ].join(' ');
}

圆的重叠

我们有一个胶粘的Metaball!但你会注意到,当圆圈开始重叠时,path会变得很怪异。我们可以通过扩大这个比例来解决这个问题,这个比例要与圆圈重叠的程度成比例。

可以利用u1u2来控制扩容。可以使用余弦定理来计算。

radius1 d radius2 u1 u2
u1 = Math.acos(
    (radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
);

u2 = Math.acos(
    (radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
);

但是要怎么处理这些,说实话,我也不知道如何。我所知道的就是,随着圆圈越来越近,它会扩展开来,一旦circle2完全在circle1内时,它就会坍塌。

const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v;
const angle2 = angleBetweenCenters - (u1 + (maxSpread - u1) * v);
const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle4 = angleBetweenCenters - (Math.PI - u2 - (Math.PI - u2 - maxSpread) * v);

最后一个关键就是重叠的圆。手柄的长度也与圆的距离成比例。

// 通过曲线两端之间的距离来定义手柄长度
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius);

// 圆圈重叠时
const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));

const r1 = radius1 * d2;
const r2 = radius2 * d2;

总结

这是最终的结果和Metaball的整个代码。试着用不同的手形和不同的值来处理它,看看它是如何影响连器的形状的。在代码第70行中有许多令人惊讶的小细节。在@Hiroyuki Sato的作品中,我学到了很多东西。

/**
* Based on Metaball script by Hiroyuki Sato
* http://shspage.com/aijs/en/#metaball
*/
function metaball(radius1, radius2, center1, center2, handleSize = 2.4, v = 0.5) {
    const HALF_PI = Math.PI / 2;
    const d = dist(center1, center2);
    const maxDist = radius1 + radius2 * 2.5;
    let u1, u2;

    // No blob if a radius is 0
    // or if distance between the circles is larger than max-dist
    // or if circle2 is completely inside circle1
    if (radius1 === 0 || radius2 === 0 || d > maxDist || d <= Math.abs(radius1 - radius2)) {
        return '';
    }

    // Calculate u1 and u2 if the circles are overlapping
    if (d < radius1 + radius2) {
        u1 = Math.acos(
        (radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d),
        );
        u2 = Math.acos(
        (radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d),
        );
    } else { // Else set u1 and u2 to zero
        u1 = 0;
        u2 = 0;
    }

    // Calculate the max spread
    const angleBetweenCenters = angle(center2, center1);
    const maxSpread = Math.acos((radius1 - radius2) / d);
    // Angles for the points
    const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v;
    const angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v;
    const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
    const angle4 = angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - maxSpread) * v;

    // Point locations
    const p1 = getVector(center1, angle1, radius1);
    const p2 = getVector(center1, angle2, radius1);
    const p3 = getVector(center2, angle3, radius2);
    const p4 = getVector(center2, angle4, radius2);

    // Define handle length by the distance between both ends of the curve
    const totalRadius = radius1 + radius2;
    const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
    // Take into account when circles are overlapping
    const d2 = d2Base * Math.min(1, d * 2 / (radius1 + radius2));

    // Length of the handles
    const r1 = radius1 * d2;
    const r2 = radius2 * d2;

    // Handle locations
    const h1 = getVector(p1, angle1 - HALF_PI, r1);
    const h2 = getVector(p2, angle2 + HALF_PI, r1);
    const h3 = getVector(p3, angle3 + HALF_PI, r2);
    const h4 = getVector(p4, angle4 - HALF_PI, r2);

    // Generate the connector path
    return metaballToPath(
        p1, p2, p3, p4,
        h1, h2, h3, h4,
        d > radius1,
        radius2,
    );
}

function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
return [
    'M', p1,
    'C', h1, h3, p3,
    'A', r, r, 0, escaped ? 1 : 0, 0, p4,
    'C', h4, h3, p4,
].join(' ');
}

本文根据@winkerVSbecks的《Metaballs》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:http://varun.ca/metaballs/

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/svg/metaballs.htmljordan retro 11 mens zappos