
🔗 이 포스트에 대해 더 궁금하신가요? 다음 주소를 참고해주세요!
🗒️ 이 글의 수정 내역 (마지막 수정 일자: 없음)
드디어 이것으로 메타볼 애니메이션 구현 시리즈를 마칠 수 있게 됐군요!
사실 처음에는 힘들었는데요. 제가 이뤘던 것들을 간만에 다시 살펴보니, 꽤나 저 역시 이를 이해하느라 고생 많이 했다는 것을 다시금 느꼈습니다. 🥰
여튼, 기존에 했던 융합은 캔버스의 전체 픽셀에 대해 완전탐색을 하고 있었죠?
그리고 이로 인한 가장 큰 한계는 성능이 굉장히 느리다는 것이었습니다.
그렇다면 저는 어떻게 이를 해결해나갔는지, 한 번 살펴봅시다!
일단 기존에는 픽셀을 하나의 공간이라 치면, 그 공간에 대해 각 메타볼 도형간의 영향력을 고려하여, 이 영향력이 역치를 넘어서면 융합하는 방식으로 구현을 했습니다.
그런데 생각해봅시다.
애초에 메타볼끼리만 비교하면 안될까요? 🥹
물론 메타볼의 공식 그대로 구현하는 것이 가장 정확하지만, 저는 메타볼이 융합하는 느낌만 가져가도 충분했습니다.
따라서 paperJS라는 캔버스 라이브러리를 참고하며 다음과 같이 하나씩 정복해나갔어요.
path로 그려내며 결과값을 반환한다.이전 포스트에서 FuseStrategy를 만들었던 것 기억나시나요?
이제 이를 대체할 새로운 FuseStrategy의 뼈대를 생성해볼게요.
before?: (...args: unknown[]) => void;
after?: (...args: unknown[]) => void;
constructor(public fuseWeight: number = 1.2) {}
setBefore(callback: (...args: unknown[]) => void) {
this.before = callback.bind(this);
}
setAfter(callback: (...args: unknown[]) => void) {
this.after = callback.bind(this);
}
setFuseWeight(weight: number) {
this.fuseWeight = weight;
}
exec(ctx: Canvas['ctx'], balls: (DynamicMetaball | StaticMetaball)[]) {
}
이후에는 이 객체를 기존의 fuseStrategy에 대해 대체해봅시다.
function main() {
const moveStrategy = new MoveStrategy();
const drawStrategy = new DrawStrategy();
// const fuseStrategy = new FuseStrategy();
const fuseStrategy = new OptimizedFuseStrategy();
app.setDynamicMetaballMove({
moveStrategy,
key: EMetaballObserverKeys.dynamic,
});
app.setDynamicMetaballDraw({
drawStrategy,
key: EMetaballObserverKeys.dynamic,
});
moveStrategy.setBefore(() => {
fuseStrategy.exec(app.canvasCtx, app.allMetaballs);
});
app.mount($target);
}
main();
어떤가요?
원래였다면 이를 대체하기 위해 새로운 메타볼 클래스를 만들어서 상속해야 했는데, 그냥 전략만 대체함으로써 해결해냈어요.
물론 어느 상황에서나 좋은 건 아니지만, 그래도 객체 지향적으로 문제를 개방-폐쇄 원칙에 맞게 잘 해결하게 되었군요!
그럼, 이제 본격적으로 로직을 분석하러 가볼까요?
일단 exec이 실행되면, 원들 간의 융합을 할지말지를 결정해야 합니다.
이를 위해서는 필수적으로 각 원들간의 비교가 선행돼요.
이를 한 번 exec에서 구현해봅시다.
export class OptimizedFuseStrategy implements Strategy {
// 기존 코드 생략 ...
exec(ctx: Canvas['ctx'], balls: (DynamicMetaball | StaticMetaball)[]) {
for (let i = 0; i < balls.length; i += 1) {
const nowBall = balls[i];
for (let j = i; j < balls.length; j += 1) {
const cmpBall = balls[j];
this.fuse(ctx, nowBall, cmpBall);
}
}
}
fuse(
ctx: Canvas['ctx'],
ball1: DynamicMetaball | StaticMetaball,
ball2: DynamicMetaball | StaticMetaball,
) {}
}
모든 메타볼들에 대해 비교를 해줌으로써 융합에 대해 fuse 메서드에서 판단하고 그려줄 거에요.
그렇다면 이제, fuse를 구현하러 가봅시다.
메타볼이 융합이 될지 여부는, 서로간에 가까운지에 대한 여부겠죠?
따라서 먼저 두 원을 불러준 다음, 현재의 거리와 최대 거리를 구해줍시다.
const {x: x1, y: y1, r: r1} = ball1;
const {x: x2, y: y2, r: r2} = ball2;
const totalRadiusSum = r1 + r2;
const getDist = (x1: number, y1: number, x2: number, y2: number): number =>
Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
const dist = getDist(x1, y1, x2, y2);
const maxDist = totalRadiusSum * this.fuseWeight;
if (dist >= maxDist) {
return;
}
이제 결과적으로 최대 거리를 넘어가면 계산을 하지 않도록 early return하게 되었어요! 👏🏻
그렇다면 이제 이전의 코드를 넘어가면 모두 융합해야 하는 메타볼이겠죠?!
우리는 이후 두 원의 내접점에 대한 각도를 구해야 해요. 이를 통해 최소 확산 거리를 알 수 있게 됩니다.
왜냐하면, 두 내 접점 미만의 각도로는 이미 합쳐져 있으니 융합을 계산할 필요가 없기 때문이죠.
// 기존 코드 생략...
const isOverlapping = dist < totalRadiusSum;
const squaredR1 = r1 ** 2;
const squaredR2 = r2 ** 2;
const squaredDist = dist ** 2;
/**
* @description
* 내접하는 각 메타볼의 가운데와 접점을 이어 삼각형을 만들었을 때, 해당 각도를 구하는 공식.
* 세 변을 알 수 있다면 각도를 구할 수 있다.
* @see: https://en.wikipedia.org/wiki/Law_of_cosines
*/
const u1 = isOverlapping
? Math.acos((squaredDist + squaredR1 - squaredR2) / (2 * r1 * dist))
: 0;
const u2 = isOverlapping
? Math.acos((squaredDist + squaredR2 - squaredR1) / (2 * r2 * dist))
: 0;
반대로 왜 외접점을 구해야 하는지는 눈치가 빠르시다면 이해하셨을 것 같아요! 🙇🏻♂️
가령 메타볼이 존재한다면, 마치 융합하는 듯한 사이의 애니메이션은 두 원의 외접점들을 넘어갈 수 없어요. 따라서 이 외접점을 미리 구함으로써 우리는 유효한 거리에 있는 두 원 간에 대해 최대로 늘릴 수 있는 limit를 정해줄 수 있습니다.
이를 구하기 위해 여러 자료를 찾아본 결과, 다음 공식이 유효했어요.
이를 통해 현재 메타볼의 외접점과 중심, 그리고 비교할 메타볼의 중심을 이었을 때의 각도를 알 수 있습니다.
const maxSpread = Math.acos((r1 - r2) / dist);
이후 이 확산할 수 있는 각도를 찾았다면, 외접점을 구해주면 됩니다.
이때, 우리는 융합에 대한 좌표를 찾아야 하는데요. 미리 이를 구할 함수 getVector을 해당 클래스의 메서드로 구현해놓을게요.
getVector(
x: number,
y: number,
angle: number,
radius: number,
): [number, number] {
return [x + radius * Math.cos(angle), y + radius * Math.sin(angle)];
}
const v = 0.5;
/**
* @description
* 결국 두 원의 중심 간에 애초에 존재하던 각도를 베이스로 잡기 위해 정의한다.
*/
const baseAngle = getAngle(x2, y2, x1, y1);
const spreadV1 = u1 + (maxSpread - u1) * v;
const spreadV2 = Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle1a = baseAngle + spreadV1;
const angle1b = baseAngle - spreadV1;
const angle2a = baseAngle + spreadV2;
const angle2b = baseAngle - spreadV2;
const p1a = this.getVector(x1, y1, angle1a, r1);
const p1b = this.getVector(x1, y1, angle1b, r1);
const p2a = this.getVector(x2, y2, angle2a, r2);
const p2b = this.getVector(x2, y2, angle2b, r2);
여기서 특이한 점은 v입니다. v는 무엇이길래 0.5라는 수치를 spreadV1, spreadV2에 곱해주는 걸까요?
저 역시 이 값이 이해가 되지 않았으나 추측하기로는 평균을 내기 위함인 것 같아요.
결국 외접점에 대한 최대 각도와, 내접점에 대한 최소 각도의 평균으로 가중치를 줌으로써, 메타볼의 융합하는 효과를 적절하게 핸들링하는 것으로 파악할 수 있습니다!
이후에는 해당 각도를 구하여(angle), 융합하는 곳을 그릴 각 4지점 좌표를 구합니다.
휴! 이제 거의 다 구현했어요.
이제 핸들할 곳을 구할 건데요. 이 용도는, 추후 베지어 곡선을 그려, 자연스러운 융합 효과를 구현하는 데 사용할 거에요.
const handleLength = 2.4;
const baseHandleDist =
Math.min(v * handleLength, getDist(...p1a, ...p2a) / totalRadiusSum) *
Math.min(1, (dist * 2) / totalRadiusSum);
const handleRadius1 = r1 * baseHandleDist;
const handleRadius2 = r2 * baseHandleDist;
const h1a = this.getVector(...p1a, angle1a - PIH, handleRadius1);
const h1b = this.getVector(...p1b, angle1b + PIH, handleRadius1);
const h2a = this.getVector(...p2a, angle2a + PIH, handleRadius2);
const h2b = this.getVector(...p2b, angle2b - PIH, handleRadius2);
휴! 결과적으로 우리는 필요한 정보들을 모두 구했네요.
최종적으로 그릴 시간입니다.
여기서는 bazierCurve라는 것을 사용할 거에요. 이를 통해 핸들 축을 2개 지정하여 베지에 곡선을 그림으로써 자연스러운 곡선을 연출할 거에요.
ctx.beginPath();
ctx.moveTo(...p1a);
ctx.bezierCurveTo(...h1a, ...h2a, ...p2a);
ctx.lineTo(...p2b);
ctx.bezierCurveTo(...h2b, ...h1b, ...p1b);
ctx.closePath();
ctx.fill();
자, 이제 모든 게 끝났어요.
한 번 결과를 확인해볼까요?

잘 나오는군요!
export class VectorFuseStrategy implements Strategy {
before?: (...args: unknown[]) => void;
after?: (...args: unknown[]) => void;
constructor(public fuseWeight: number = 1.2) {}
setBefore(callback: (...args: unknown[]) => void) {
this.before = callback.bind(this);
}
setAfter(callback: (...args: unknown[]) => void) {
this.after = callback.bind(this);
}
setFuseWeight(weight: number) {
this.fuseWeight = weight;
}
exec(ctx: Canvas['ctx'], balls: (DynamicMetaball | StaticMetaball)[]) {
for (let i = 0; i < balls.length; i += 1) {
const nowBall = balls[i];
for (let j = 0; j < balls.length; j += 1) {
const cmpBall = balls[j];
this.fuse(ctx, nowBall, cmpBall);
}
}
}
getVector(
x: number,
y: number,
angle: number,
radius: number,
): [number, number] {
return [x + radius * Math.cos(angle), y + radius * Math.sin(angle)];
}
fuse(
ctx: Canvas['ctx'],
ball1: DynamicMetaball | StaticMetaball,
ball2: DynamicMetaball | StaticMetaball,
) {
const {x: x1, y: y1, r: r1} = ball1;
const {x: x2, y: y2, r: r2} = ball2;
/**
* @see: https://github.com/paperjs/paper.js/blob/develop/examples/Paperjs.org/MetaBalls.html
*/
const v = 0.5;
const handleLength = 2.4;
const totalRadiusSum = r1 + r2;
const dist = getDist(x1, y1, x2, y2);
const maxDist = totalRadiusSum * this.fuseWeight;
if (dist >= maxDist) {
return;
}
const maxSpread = Math.acos((r1 - r2) / dist);
const isOverlapping = dist < totalRadiusSum;
const squaredR1 = r1 ** 2;
const squaredR2 = r2 ** 2;
const squaredDist = dist ** 2;
/**
* @description
* 내접하는 각 메타볼의 가운데와 접점을 이어 삼각형을 만들었을 때, 해당 각도를 구하는 공식.
* 세 변을 알 수 있다면 각도를 구할 수 있다.
* @see: https://en.wikipedia.org/wiki/Law_of_cosines
*/
const u1 = isOverlapping
? Math.acos((squaredDist + squaredR1 - squaredR2) / (2 * r1 * dist))
: 0;
const u2 = isOverlapping
? Math.acos((squaredDist + squaredR2 - squaredR1) / (2 * r2 * dist))
: 0;
/**
* @description
* 결국 두 원의 중심 간에 애초에 존재하던 각도를 베이스로 잡기 위해 정의한다.
*/
const baseAngle = getAngle(x2, y2, x1, y1);
const spreadV1 = u1 + (maxSpread - u1) * v;
const spreadV2 = Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle1a = baseAngle + spreadV1;
const angle1b = baseAngle - spreadV1;
const angle2a = baseAngle + spreadV2;
const angle2b = baseAngle - spreadV2;
const p1a = this.getVector(x1, y1, angle1a, r1);
const p1b = this.getVector(x1, y1, angle1b, r1);
const p2a = this.getVector(x2, y2, angle2a, r2);
const p2b = this.getVector(x2, y2, angle2b, r2);
const baseHandleDist =
Math.min(v * handleLength, getDist(...p1a, ...p2a) / totalRadiusSum) *
Math.min(1, (dist * 2) / totalRadiusSum);
const handleRadius1 = r1 * baseHandleDist;
const handleRadius2 = r2 * baseHandleDist;
const h1a = this.getVector(...p1a, angle1a - PIH, handleRadius1);
const h1b = this.getVector(...p1b, angle1b + PIH, handleRadius1);
const h2a = this.getVector(...p2a, angle2a + PIH, handleRadius2);
const h2b = this.getVector(...p2b, angle2b - PIH, handleRadius2);
ctx.beginPath();
ctx.moveTo(...p1a);
ctx.bezierCurveTo(...h1a, ...h2a, ...p2a);
ctx.lineTo(...p2b);
ctx.bezierCurveTo(...h2b, ...h1b, ...p1b);
ctx.closePath();
ctx.fill();
}
}
드디어! 퍼포먼스를 비교할 수 있게 되었어요. 한 번 성능을 살펴볼까요?
똑같은 조건에서 동일하게 10초간 아무런 동작 없이 렌더링한 결과입니다.

헉! 스크립트 성능이 약 50~60배를 왔다갔다 하는군요 🫣
유휴 상태 역시 적지는 않지만 넉넉하기에, 이는 필요한 애니메이션이라면 실제로 쓸 수 있겠군요!

함수 호출 시간 역시 상당히 짧아졌음을 확인했습니다.
그러나 분명 단점 역시 존재해요.
sync가 맞지 않는 이슈가 생깁니다. 이는 어쩔 수 없다고 생각하는 게, canvas는 래스터 기반으로 비트맵 단위로 조작할 수 있게되는데요. 소수점 단위까지는 커버할 수 없기에 발생한 이슈라 생각해요.제가 가장 애정을 쏟았던 작업이니만큼, 꽤나 오랜 시간이 걸렸네요.
힘도 빠질 법 하지만, 오히려 기분이 좋았어요.
실제로 수정할 때마다 delete되는 코드들이 거의 없고, 확장에만 코드 작성이 집중되었음을 확인하며 설계가 나쁘지 않았다는 것을 느꼈어요.
아무래도 이제 저의 경우, 포트폴리오 사이트를 바꿀 계획인데요. 바꾸게 되면 이 코드는 사용하지 않게 될지도 모르겠네요. 그러나 누군가에게 어떤 방식으로든 도움이 되었으면 좋겠어서 글로 남기게 되었네요.
그렇다면, 긴 글 읽으시느라 고생하셨습니다 🥰
재밌는 시리즈네요!