스킬 이펙트는 검정 위에 빛을 더해 그렸다

번개, 화염, 검기, 레벨업까지 이펙트 아홉 개를 파이썬 파일 하나로 뽑았다. 빛은 검정 위에 더해서 그리고, 투명판의 알파는 그 밝기에서 뽑아낸다.

번개가 화면 위에서 내리꽂히는 0.9초짜리 영상이 필요했다. 화염 폭발도, 검기 베기도, 레벨업 빛기둥도. 이펙트 팩을 사거나 유료 툴을 켜는 대신 파이썬 파일 하나를 열었다. tools/fx.py. 쓰는 건 Pillow 와 numpy, ffmpeg 셋뿐이다.

돌리면 아홉 개가 나온다. 닉네임이 펑 터지는 것 세 벌(흰색, 빨강, NPC 주황), 번개 강타, 화염 폭발, 검기 베기, 타격 충격파, 레벨업, 기 모으기. 이펙트마다 파일이 두 개씩 생긴다. 하나는 알파가 살아 있는 -투명.mov, 하나는 검정 바탕의 -검정.mp4. 둘 다 같은 프레임에서 나온다. 그 프레임을 어떻게 그리느냐가 이 파일의 전부다.

이펙트는 빛 배열과 글자 한 장으로 이뤄진다

아홉 개 함수는 모양이 같다. 전부 (W, H, frames) 를 돌려주고, 프레임 하나는 둘로 갈린다.

python
# ───────── 이펙트들 — 각 함수는 (W, H, frames) 를 돌려준다. frame = (빛 float 배열, 위에 얹을 RGBA 또는 None)

앞쪽은 0~1 범위를 넘을 수도 있는 float 배열이다. 번개 줄기, 파편, 충격 고리처럼 스스로 빛나는 것은 전부 여기에 그린다. 뒤쪽은 그 위에 그냥 덮을 RGBA 이미지다. 「Lv.25 오리」 같은 닉네임이나 「LEVEL UP!」 글자가 여기 들어간다. 글자가 없는 이펙트는 None 이다.

화면 크기는 쓰임에 따라 다르다. 번개와 레벨업은 위아래로 길게 뻗으니 1080×1920 세로판이고, 화염과 베기, 충격파, 기 모으기는 1080×1080 정사각이다. 닉네임 펑은 글자 한 줄이면 되니까 1080×520. 길이도 제각각이다. 베기와 충격파는 0.55초로 짧고, 레벨업은 1.8초로 제일 길다. 모두 30fps 로 뽑는다.

난수는 전부 random.Random(seed) 로 씨앗을 고정했다. 파편이 날아가는 방향도, 번개가 꺾이는 자리도 매번 같다. 파일을 다시 돌려도 같은 영상이 나온다. 닉네임 펑 세 벌은 씨앗을 1, 2, 3 으로 달리 줘서 파편 배치가 서로 다르다.

빛은 검정 위에 더한다

그림을 그리는 캔버스는 언제나 Image.new("RGB", (W, H)), 즉 새까만 판이다. 그 위에 밝은 색으로 선과 원을 그린다. 사라지는 효과는 투명도를 낮추지 않고 색 자체를 어둡게 해서 낸다. 충격 고리가 퍼지면서 흐려지는 건 a = int(255 * (1 - t / .35)) 처럼 밝기 값이 0 으로 내려가는 것이다. 검정에 가까워지면 곧 안 보이는 것과 같다.

불꽃 색은 수명에 따라 흰색에서 노랑, 주황, 빨강을 거쳐 거의 검정으로 떨어진다. 다섯 개 정지점을 선형으로 잇는 heat() 하나가 그 일을 한다. 화염 폭발은 이 색을 입힌 입자 110개를 크기 순으로, 큰 것부터 그린다. 작은 불똥이 큰 불덩이에 묻히지 않게 하려는 순서다.

번개는 조금 다르게 움직인다. 줄기 모양을 매 프레임 새로 뽑지 않고 세 프레임에 한 번 바꾼다(bolt(seed + i // 3)). 그리고 세 프레임 중 하나는 아예 줄기를 그리지 않는다(i % 3 != 2). 모양이 바뀌면서 깜빡이니 전기가 튀는 느낌이 난다. 첫 0.12초에는 화면 전체에 0.35 를 더해 번쩍이게 했다. 배열에 숫자를 더하는 한 줄이다.

번짐은 블러 세 겹을 더한 것이다

선명한 선만으로는 빛처럼 안 보인다. 빛은 둘레로 번진다. 그 번짐을 내는 함수가 glow() 다.

python
def glow(img, rad=(3, 10, 28), w=(1.0, .8, .6)):
    """선명한 빛 그림에 번짐을 더한다 → float 배열."""
    a = np.asarray(img, dtype=np.float32) / 255
    out = a.copy()
    for r, k in zip(rad, w):
        out += np.asarray(img.filter(ImageFilter.GaussianBlur(r)), dtype=np.float32) / 255 * k
    return out

원본에 반경이 다른 가우시안 블러 세 장을 가중치를 붙여 더한다. 작은 블러는 선 바로 옆의 단단한 빛을, 큰 블러는 넓게 퍼지는 아지랑이를 맡는다. 더하기니까 가운데는 1 을 훌쩍 넘는다. 기본 가중치로는 최대 2.4 까지 간다. 넘친 값은 내보낼 때 잘려서 하얗게 타 버린 심지가 된다. 이펙트마다 반경을 바꿔 쓰는데, 번개는 (4, 16, 50), 레벨업은 (6, 24, 60) 처럼 크게 잡아 더 멀리 번지게 했다.

블러를 곱하지 않고 더하는 이유는 단순하다. 빛 두 개가 겹치면 더 밝아져야 한다. 검정 위에 더하는 방식이면 이게 저절로 된다.

레벨업 한 장면을 뜯어 보면

1.8초짜리 레벨업에는 부품이 넷 있다. 먼저 빛기둥. 폭이 200, 110, 40 인 사각형 세 개를 세기 0.25, 0.5, 1 로 겹쳐 그린다. 바깥은 흐리고 가운데는 진하다. 기둥은 처음 0.35초 동안 바닥에서 1500px 위까지 솟고, 전체 밝기는 처음 0.25초에 켜졌다가 마지막 0.5초에 꺼진다.

발밑에는 납작한 타원 고리 두 개가 위상을 반씩 어긋나게 퍼져 나간다. 하나가 사라질 즈음 다른 하나가 커지니 끊기지 않고 맥박처럼 뛴다. 그 사이로 빛 알갱이 70개가 위로 올라가는데, 밝기는 사인 곡선이라 올라가면서 켜졌다가 꼭대기에서 꺼진다. 마지막으로 0.25초가 지나면 「LEVEL UP!」 글자가 튀어나온다.

부품마다 하는 일은 단순하다. 사각형, 타원, 점. 그걸 시간에 따라 켜고 끄는 곡선이 몇 개 붙어 있을 뿐이다. 나머지 이펙트도 다르지 않다. 기 모으기는 입자 90개가 반지름 480 바깥에서 돌며 가운데로 빨려 들어가고, 가운데 원이 빠르게 맥박친다. 충격파는 고리 둘과 바깥으로 튀는 빛줄기 22개다.

밝기를 알파로 바꾸면 투명판이 나온다

검정 바탕 영상은 만들기 쉽다. 배열을 0~1 로 자르고 8비트로 바꾸면 끝이다. 문제는 투명판이다. 편집 앱 위에 얹으려면 검정 대신 투명이 필요한데, 이 그림에는 처음부터 알파가 없었다. 그럼 알파를 어디서 가져와야 할까?

밝기에서 가져온다.

python
def to_rgba(light, top):
    a = np.clip(light.max(axis=2), 0, 1)
    rgb = np.clip(light / np.maximum(a[..., None], 1e-4), 0, 1)
    img = Image.fromarray((np.dstack([rgb, a]) * 255).astype(np.uint8), "RGBA")
    if top is not None: img.alpha_composite(top)
    return img

픽셀마다 R, G, B 중 제일 큰 값을 알파로 삼고, 색은 그 알파로 나눈다. 어두운 주황 (0.4, 0.2, 0.0) 은 알파 0.4 짜리 밝은 주황 (1.0, 0.5, 0.0) 이 된다. 이걸 다시 검정 위에 얹으면 원래 색이 정확히 돌아온다. 0 으로 나누는 걸 막으려고 분모에 1e-4 하한을 걸었다. 새까만 픽셀은 알파 0, 완전히 투명해진다.

투명판은 ffmpeg 의 prores_ks 인코더로 ProRes 4444 프로필, yuva444p10le 픽셀 형식으로 묶는다. 이름 끝의 a 가 알파 채널이다. 파일 설명에는 캡컷 PC, 프리미어, 다빈치에 그대로 얹는다고 적어 두었다.

글자는 빛이 아니라서 따로 얹는다

닉네임 글자에는 두께 7 의 거의 검정에 가까운 테두리가 있다. stroke_fill=(15, 15, 20, 255). 이걸 빛 배열에 그리면 어떻게 될까? 밝기가 0 에 가까우니 알파도 0 에 가까워지고, 투명판에서 테두리가 통째로 사라진다. 어두운 색은 「더하는 빛」으로 표현할 수 없다.

그래서 프레임을 둘로 나눴다. 빛은 배열로, 글자는 RGBA 이미지로 따로 그리고, 내보낼 때 마지막에 alpha_composite 로 덮는다. 글자 뒤의 은은한 후광만 빛 쪽에 넣는다. 글자를 덮는 판은 테두리째로 남는다.

글자의 움직임은 크기 하나로 낸다. 0.12초 동안 1.35배까지 튀어나왔다가, 그 뒤로는 지수로 줄어드는 코사인을 곱해 1 둘레에서 출렁이다 멈춘다. 처음 0.25초는 제 색 대신 하얗게 달아올랐다가 원래 색으로 식는다.

폰 캡컷에서는 검정판을 스크린으로

투명판은 PC 편집 앱 몫이다. 폰 캡컷을 위해서는 두 번째 파일을 따로 냈다. -검정.mp4 는 libx264, yuv420p, crf 16 으로 묶은 평범한 영상이다. 이걸 영상 위에 올리고 혼합 모드를 「스크린」으로 두면 검정이 사라진다.

스크린 합성은 두 밝기를 반전해서 곱한 뒤 다시 반전한다. 위 레이어가 검정이면 아래 화면이 그대로 남고, 밝은 곳만 아래를 더 밝힌다. 처음부터 검정 위에 더하는 빛으로 그렸으니 이 모드와 궁합이 맞는다. 다만 글자 테두리처럼 어두운 부분은 스크린에서도 사라진다. 테두리까지 살려야 하는 닉네임 펑은 투명판 쪽이 맞다.

마지막으로 아홉 개를 1080×1920 회색 바탕에 이어 붙인 _미리보기.mp4 가 하나 더 나온다. 이펙트가 끝날 때마다 마지막 프레임을 8장 더 붙여 잠깐 멈춘다. 폴더를 열자마자 전부 훑어볼 수 있다.

핵심 한 줄빛은 검정 위에 더해서 그리고, 알파는 밝기에서 뽑는다. 어두운 것은 빛이 아니니 따로 덮는다.

자주 묻는 것

검정 배경 이펙트 영상을 투명하게 쓰는 법

편집 앱에서 혼합 모드를 스크린으로 두면 검정이 사라지고 밝은 부분만 남는다. 다만 글자 테두리처럼 어두운 부분도 같이 사라지니, 그런 이펙트는 알파가 있는 ProRes 4444 판을 쓰는 게 맞다.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 혼자 게임을 만들며 막힌 곳을 한 편씩 적는다. 같은 데서 두 번 막히지 않으려고 쓰는 기록이다.

게임 개발 글 10편 모두 보기 →