Metaball变形球效果实现

    技术2023-08-07  65

    效果图

    一 .求出圆的外公切线

    两个圆心之间的距离,也就是两点之间的距离 const distanceBetweenCenter = Math.sqrt(Math.pow(c1.cx - c2.cx, 2) + Math.pow(c1.cy - c2.cy, 2)); 计算直线C1C2与水平线的角度 const angleBetweenCenters = Math.atan2(c2.cy - c1.cy, c2.cx - c1.cx); 角P2C1C2可以根据公式求出 const spread = Math.acos((c1.radius - c2.radius) / distanceBetweenCenter); 那么就可以算出四个点的角度,注意,按照上面的图,angleBetweenCenters计算出来的是负数,spread是正数, 所以angle1就是p1点的角度,angle2就是p2点的角度,以此类推。

    const angle1 = angleBetweenCenters + spread; const angle2 = angleBetweenCenters - spread; const angle3 = angleBetweenCenters + spread; const angle4 = angleBetweenCenters - spread;

    根据圆的半径与坐标,就可以求出切点坐标

    const p1 = getVector(c1.cx, c1.cy, angle1, c1.radius); const p2 = getVector(c1.cx, c1.cy, angle2, c1.radius); const p3 = getVector(c2.cx, c2.cy, angle3, c2.radius); const p4 = getVector(c2.cx, c2.cy, angle4, c2.radius); function getVector(cx, cy, a, r) { return {x: cx + r * Math.cos(a), y: cy + r * Math.sin(a)}; }

    二.偏移切点位置

    如果在这样的切点做变形球效果,肯定不好看,我们给出一个系数v=0.5,让切点稍微一点偏移,也就是向两圆直接偏移,注意,下面的代码和上面的角度计算不一样,不如 角P4C2C1 =Math.PI - spread,这个角度乘上v,是不是变得更小了,然后Math.PI - 角P4C2C1 也就变得更大了,这样P4就会向圆C1偏移了。

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

    计算控制点

    这里需要用贝塞尔曲线了,而且还是三阶的,这就需要二个控制点 先计算两个圆圆心的距离totalRadius,想要随着圆的位置变化,控制点也需要变,给出d2比例参数 这里的HALF_PI是因为切点方向。

    const d2 = (dist(p1.x, p1.y, p3.x, p3.y) / totalRadius); const r1 = c1.radius * d2; const r2 = c2.radius * d2; const h1 = this.getVector(p1.x, p1.y, angle1 - HALF_PI, r1); const h2 = this.getVector(p2.x, p2.y, angle2 + HALF_PI, r1); const h3 = this.getVector(p3.x, p3.y, angle3 + HALF_PI, r2); const h4 = this.getVector(p4.x, p4.y, angle4 - HALF_PI, r2);

    三.画曲线

    现在我们有切点有控制点,可以画曲线了,

    ctx.beginPath(); ctx.strokeStyle = c2.color; ctx.moveTo(p1.x, p1.y); ctx.arc(c1.cx, c1.cy, c1.radius, angle1, angle2, false); ctx.bezierCurveTo(h2.x, h2.y, h4.x, h4.y, p4.x, p4.y); ctx.arc(c2.cx, c2.cy, c2.radius, angle4, angle3, false); ctx.bezierCurveTo(h3.x, h3.y, h1.x, h1.y, p1.x, p1.y); ctx.stroke();

    但是现在要是将两个圆拉远了,就会出现下面的问题,这个问题后面会解决

    四.圆的重叠

    我们先来解决下面这个问题 当两个圆重叠时,随着圆重叠程度的增加,u1与u2的角度也会变得越来越大,如果这个时候,u1与u2参与 切点角度的运算,那么此时切点角度的变化就不会有那么大。

    let u1 = 0; let u2 = 0; if (distanceBetweenCenter < totalRadius) { //余弦定理 u1 = Math.acos((c1.radius * c1.radius + distanceBetweenCenter * distanceBetweenCenter - c2.radius * c2.radius) / (2 * c1.radius * distanceBetweenCenter)); u2 = Math.acos((c2.radius * c2.radius + distanceBetweenCenter * distanceBetweenCenter - c1.radius * c1.radius) / (2 * c2.radius * distanceBetweenCenter)); } else { u1 = 0; u2 = 0; } const angleBetweenCenters = Math.atan2(c2.cy - c1.cy, c2.cx - c1.cx); const spread = Math.acos((c1.radius - c2.radius) / distanceBetweenCenter); const angle1 = angleBetweenCenters + u1 + (spread - u1) * v; const angle2 = angleBetweenCenters - (u1 + (spread - u1) * v); const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - spread) * v; const angle4 = angleBetweenCenters - (Math.PI - u2 - (Math.PI - u2 - spread) * v);

    最后来解决两个圆拉的太远的问题,只要超过某个距离,就可以不需要画了。

    const maxDist = c1.radius + c2.radius * 3.9; const distanceBetweenCenter = Math.sqrt(Math.pow(c1.cx - c2.cx, 2) + Math.pow(c1.cy - c2.cy, 2)); if (distanceBetweenCenter >= maxDist || distanceBetweenCenter <= Math.abs(c1.radius - c2.radius)) { return; }

    最后,贴出源码:

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <style type="text/css"> body, html { width: 100%; height: 100%; } </style> </head> <body> <canvas style="width: 100%;height: 100%" id="meta-ball"></canvas> <script type="application/javascript"> const devicePixelRatio = window.devicePixelRatio || 1; let canvas = document.getElementById("meta-ball"); canvas.width = document.body.clientWidth * devicePixelRatio; canvas.height = document.body.clientHeight * devicePixelRatio; let ctx = canvas.getContext("2d"); const HALF_PI = Math.PI / 2; class Circle { constructor(cx, cy, radius, color) { this.cx = cx; this.cy = cy; this.radius = radius; this.color = color; this.dragging = false; } draw(ctx) { // ctx.fillStyle = this.color; // ctx.beginPath(); // ctx.arc(this.cx, this.cy, this.radius, 0, 2 * Math.PI); // ctx.fill(); ctx.strokeStyle = this.color; ctx.lineWidth = 2 * devicePixelRatio; ctx.beginPath(); ctx.arc(this.cx, this.cy, this.radius, 0, 2 * Math.PI); ctx.stroke(); ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(this.cx, this.cy, 4 * devicePixelRatio, 0, 2 * Math.PI); ctx.fill(); } move(x, y) { if (this.dragging) { this.cx = x; this.cy = y; } } } const c1 = new Circle(canvas.width * 0.4, canvas.height * 0.4, Math.min(canvas.width, canvas.height) * 0.2, '#432322'); const c2 = new Circle(canvas.width * 0.7, canvas.height * 0.5, Math.min(canvas.width, canvas.height) * 0.1, '#432322'); let isDrawing = false; canvas.addEventListener('mousedown', e => { isDrawing = true; let x = e.offsetX * devicePixelRatio; let y = e.offsetY * devicePixelRatio; if (Math.sqrt(Math.pow(x - c1.cx, 2) + Math.pow(y - c1.cy, 2)) <= c1.radius) { c1.dragging = true; c2.dragging = false; } if (Math.sqrt(Math.pow(x - c2.cx, 2) + Math.pow(y - c2.cy, 2)) <= c2.radius) { c1.dragging = false; c2.dragging = true; } }); canvas.addEventListener('mousemove', e => { let x = e.offsetX * devicePixelRatio; let y = e.offsetY * devicePixelRatio; if (!isDrawing) { return } update(x, y); }); canvas.addEventListener('mouseup', e => { isDrawing = false; c1.dragging = false; c2.dragging = false; }); function update(x, y) { ctx.clearRect(0, 0, canvas.width, canvas.height); c1.move(x, y); c2.move(x, y); c1.draw(ctx); c2.draw(ctx); const maxDist = c1.radius + c2.radius * 3.9; const distanceBetweenCenter = Math.sqrt(Math.pow(c1.cx - c2.cx, 2) + Math.pow(c1.cy - c2.cy, 2)); if (distanceBetweenCenter >= maxDist || distanceBetweenCenter <= Math.abs(c1.radius - c2.radius)) { return; } const totalRadius = c1.radius + c2.radius; const v = 0.5; let u1 = 0; let u2 = 0; if (distanceBetweenCenter < totalRadius) { //余弦定理 u1 = Math.acos((c1.radius * c1.radius + distanceBetweenCenter * distanceBetweenCenter - c2.radius * c2.radius) / (2 * c1.radius * distanceBetweenCenter)); u2 = Math.acos((c2.radius * c2.radius + distanceBetweenCenter * distanceBetweenCenter - c1.radius * c1.radius) / (2 * c2.radius * distanceBetweenCenter)); } else { u1 = 0; u2 = 0; } const angleBetweenCenters = Math.atan2(c2.cy - c1.cy, c2.cx - c1.cx); const spread = Math.acos((c1.radius - c2.radius) / distanceBetweenCenter); const angle1 = angleBetweenCenters + u1 + (spread - u1) * v; const angle2 = angleBetweenCenters - (u1 + (spread - u1) * v); const angle3 = angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - spread) * v; const angle4 = angleBetweenCenters - (Math.PI - u2 - (Math.PI - u2 - spread) * v); const p1 = getVector(c1.cx, c1.cy, angle1, c1.radius); const p2 = getVector(c1.cx, c1.cy, angle2, c1.radius); const p3 = getVector(c2.cx, c2.cy, angle3, c2.radius); const p4 = getVector(c2.cx, c2.cy, angle4, c2.radius); // drawLine(c1.cx, c1.cy, p1.x, p1.y, c1.color); // drawLine(c1.cx, c1.cy, p2.x, p2.y, c1.color); // drawLine(c2.cx, c2.cy, p3.x, p3.y, c2.color); // drawLine(c2.cx, c2.cy, p4.x, p4.y, c2.color); // drawLine(c2.cx, c2.cy, c1.cx, c1.cy, c2.color); // drawLine(p1.x, p1.y, p3.x, p3.y, "#761242"); // drawLine(p2.x, p2.y, p4.x, p4.y, "#761242"); // drawCircle(p1.x, p1.y, 4 * devicePixelRatio, '#f00'); // drawCircle(p2.x, p2.y, 4 * devicePixelRatio, '#f00'); // drawCircle(p3.x, p3.y, 4 * devicePixelRatio, '#f00'); // drawCircle(p4.x, p4.y, 4 * devicePixelRatio, '#f00'); // drawLine(p1.x, p1.y, p3.x, p3.y, '#979798'); // drawLine(p2.x, p2.y, p4.x, p4.y, '#979798'); // drawText("p1", p1.x, p1.y, '#f00'); // drawText("p2", p2.x, p2.y, '#f00'); // drawText("p3", p3.x, p3.y, '#f00'); // drawText("p4", p4.x, p4.y, '#f00'); // drawText("c1", c1.cx, c1.cy, c1.color); // drawText("c2", c2.cx, c2.cy, c2.color); const d2 = (dist(p1.x, p1.y, p3.x, p3.y) / totalRadius); const r1 = c1.radius * d2; const r2 = c2.radius * d2; const h1 = this.getVector(p1.x, p1.y, angle1 - HALF_PI, r1); const h2 = this.getVector(p2.x, p2.y, angle2 + HALF_PI, r1); const h3 = this.getVector(p3.x, p3.y, angle3 + HALF_PI, r2); const h4 = this.getVector(p4.x, p4.y, angle4 - HALF_PI, r2); // ctx.beginPath(); // ctx.fillStyle = c2.color; // ctx.moveTo(p1.x, p1.y); // ctx.arc(c1.cx, c1.cy, c1.radius, angle1, angle2, false); // ctx.bezierCurveTo(h2.x, h2.y, h4.x, h4.y, p4.x, p4.y); // ctx.arc(c2.cx, c2.cy, c2.radius, angle4, angle3, false); // ctx.bezierCurveTo(h3.x, h3.y, h1.x, h1.y, p1.x, p1.y); // ctx.fill(); ctx.beginPath(); ctx.strokeStyle = c2.color; ctx.moveTo(p1.x, p1.y); ctx.arc(c1.cx, c1.cy, c1.radius, angle1, angle2, false); ctx.bezierCurveTo(h2.x, h2.y, h4.x, h4.y, p4.x, p4.y); ctx.arc(c2.cx, c2.cy, c2.radius, angle4, angle3, false); ctx.bezierCurveTo(h3.x, h3.y, h1.x, h1.y, p1.x, p1.y); ctx.stroke(); // drawLine(p1.x, p1.y, h1.x, h1.y, "#7ee987"); // drawLine(p2.x, p2.y, h2.x, h2.y, "#7ee987"); // drawLine(p3.x, p3.y, h3.x, h3.y, "#7ee987"); // drawLine(p4.x, p4.y, h4.x, h4.y, "#7ee987"); // // // drawCircle(h1.x, h1.y, 4 * devicePixelRatio, '#a24444'); // drawCircle(h2.x, h2.y, 4 * devicePixelRatio, '#a24444'); // drawCircle(h3.x, h3.y, 4 * devicePixelRatio, '#a24444'); // drawCircle(h4.x, h4.y, 4 * devicePixelRatio, '#a24444'); // drawText("h1", h1.x, h1.y); // drawText("h2", h2.x, h2.y); // drawText("h3", h3.x, h3.y); // drawText("h4", h4.x, h4.y); } function getVector(cx, cy, a, r) { return {x: cx + r * Math.cos(a), y: cy + r * Math.sin(a)}; } function dist(x1, y1, x2, y2) { return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)); } function drawLine(x1, y1, x2, y2, color) { ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke() } function drawCircle(x, y, r, color) { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fill() } function drawText(text, x, y, color) { ctx.beginPath(); ctx.fillStyle = color; ctx.font = 30 * devicePixelRatio + 'px Arial'; ctx.fillText(text, x, y); ctx.fill() } update(0, 0); </script> </body> </html>

    文章参考:https://varun.ca/metaballs/

    Processed: 0.011, SQL: 9