테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

자연 효과

물·파도·불·연기·안개·하늘·눈·바람처럼 그리기 어려운 자연 현상을 흉내 내는 방법이다.

목차 (12)
  1. 눈 발자국: 땅이 아니라 높이 그림 한 장
  2. 수면 물결: 높이장과 파동 방정식
  3. 말리는 파도: 게르스트너 파도와 그려 둔 단면
  4. 넓은 바다: 파도 스펙트럼과 FFT
  5. 잉크 얼룩: 표면마다 든 렌더 타깃과 시드
  6. 빛줄기: 볼류메트릭 안개
  7. 우주까지 이어지는 하늘: 대기 산란과 레이 마칭
  8. 불: 그리지 않고 돌리는 유체 시뮬레이션
  9. 연막: 공간을 채운 복셀
  10. 바람: 공기가 아니라 낙엽이 읽는 값
  11. 만화처럼 칠하기: 툰 셰이딩과 외곽선
  12. 노이즈로 움직이는 효과: 디졸브, 흐름 지도, 도메인 워핑

눈 발자국: 땅이 아니라 높이 그림 한 장

눈밭에 발자국이 파이는 게임에서, 한참 돌아다니다 아까 그 자리로 오면 발자국이 사라져 있을 때가 있다. 눈이 진짜 지형이라면 남아 있어야 한다. 지형 메시의 정점을 매번 찾아 내리고 다시 GPU에 올리는 일은 넓은 맵에서 감당이 안 되기 때문에, 많은 게임은 눈을 지형과 떼어 높이 값을 적은 그림 한 장으로 둔다.

  1. 도장 찍기: 눈은 평평한 메시 그대로 두고, 캐릭터 주변 몇 m를 덮는 렌더 타깃에 "얼마나 파였나"만 적는다. 발이 닿는 순간 신발 모양의 깊이 값을 찍거나, 눈에 닿은 몸을 직교 카메라로 그 그림에 그려 넣는다. 기존 값과 최솟값으로 합쳐서 같은 자리를 또 밟아도 얕아지지 않게 한다.
  2. 누르기: 정점 셰이더가 그림에서 제 자리의 높이를 읽어 정점을 내리고, 이웃 칸과의 높이 차로 법선을 만들어 파인 벽면에 음영을 준다. 가까운 곳은 테셀레이션(tessellation)으로 면을 잘게 쪼개야 자국 모양이 산다.
  3. 따라다니기: 캐릭터가 그림 가운데에서 일정 거리 벗어나면 그림을 그만큼 밀고, 새로 들어온 띠는 새 눈으로 채운다. 주소를 감아 쓰는(ring buffer) 식으로 두면 그림 전체를 복사하지 않고 새 띠만 지우면 된다. 밀려난 쪽의 자국은 버려진다. 멀리 갔다 온 자리에 발자국이 없는 까닭이다.
  4. 메워지기: 매 프레임 높이를 0 쪽으로 조금씩 되돌려(예: h ← h × 0.999) 시간이 지나면 자국이 덮이게 한다. 처음엔 빨리, 나중엔 천천히 메워진다.
눈 발자국 높이 그림이 캐릭터를 따라다니는 모습: 오른쪽 끝까지 갔다 돌아오면 창 밖으로 밀려난 자국은 모두 버려지고, 지금 창 안에는 돌아오며 찍은 자국만 남는다. 갈 때 지나간 자리에 다시 와도 그 자국은 없다따라다니는 창(높이 그림 한 장)지금 캐릭터갈 때 찍은 자국 →← 돌아올 때창 안에 남은 자국창 밖으로 밀려나 버려진 자국(같은 자리에 와도 새 눈)
위에서 본 눈밭. 높이 그림(창)은 캐릭터를 따라다니고, 창 밖으로 밀려난 자국은 버려진다. 갈 때 지나간 자리로 돌아와도 그 자국은 없다.

맵 전체를 같은 해상도로 덮지 않는 이유는 넓이가 제곱으로 늘기 때문이다. 2cm 칸으로 1km × 1km만 덮어도 5만 × 5만 = 25억 칸이고, 칸마다 2바이트면 5GB다. 그래서 멀리 있는 자국은 해상도를 낮춰 남기거나, 창 밖으로 나간 자국을 작은 그림에 압축해 두거나, 그냥 잊는다. 배트맨: 아캄 오리진(2013)과 라이즈 오브 더 툼 레이더(2015)가 이런 변형 눈을 발표로 공개했다(GDC 2014 · SIGGRAPH 2015).

수면 물결: 높이장과 파동 방정식

캐릭터가 물을 헤치고 걸을 때 발밑에 물보라 스프라이트만 튀고 다리 뒤 수면은 평평한 게임이 많다. 물을 3D 격자로 나눠 칸마다 속도와 압력을 푸는 영화식 시뮬레이션은 한 프레임에 도저히 못 돌린다. 그래서 수면을 움직이는 게임은 보통 높이장(height field)으로 푼다. 수면을 바둑판 칸으로 나누고 칸마다 높이 h와 그 높이가 오르내리는 속도 v 두 값만 적는다. 물이 깊어져도 칸 수는 그대로다.

매 프레임 칸마다 계산하는 것은 파동 방정식을 칸으로 푼 한 줄이다.

  • v ← v + c² × (위 + 아래 + 왼쪽 + 오른쪽 높이 − 4 × h) ÷ Δx² × Δt: 이웃보다 낮은 칸은 올라가는 쪽으로, 높은 칸은 내려가는 쪽으로 속도가 바뀐다.
  • v ← v × 0.99쯤: 조금씩 줄여 물결이 끝없이 남지 않게 한다.
  • h ← h + v × Δt: 그 속도만큼 높이를 옮긴다.

c는 물결 속도, Δx는 칸 한 변, Δt는 시간 간격이다. 한 번에 너무 멀리 옮기면 값이 터지므로 c × Δt ÷ Δx가 1/√2를 넘지 않게 하고, 넘으면 한 프레임을 쪼개 여러 번 계산한다. 캐릭터와의 결합은 부피로 한다. 다리(캡슐 몇 개)가 칸에서 물 아래를 차지한 높이가 이번 프레임에 늘었으면 그만큼을 이웃 칸에 얹어, 물이 사라지지도 생기지도 않게 한다. 다만 부피만 옮기면 뛰어들어도 걸을 때와 물결이 비슷해서, 빠르게 꽂히는 순간에는 이웃 칸의 속도에도 몫을 넣어 줘야 큰 동그라미가 퍼진다.

걷는 속도 u가 물결 속도 c보다 빠르면 물결이 못 따라와 뒤로 V자가 벌어지고, 반각은 sin θ = c ÷ u다. 실제 얕은 물의 물결 속도는 √(중력 × 깊이)라 무릎 깊이만 돼도 걷기보다 빠르고, 깊은 물에서 배 뒤에 생기는 V자(약 19.5°)는 파장마다 속도가 다른 분산에서 나온다. 물결 속도가 하나뿐인 격자에서는 보기 좋은 c를 골라 V자를 만든다.

높이장은 칸마다 높이가 하나라 파도가 말려 부서지는 모양은 못 그린다. 그래서 빠르게 솟는 가파른 물마루를 찾아 그 자리에서만 물보라 입자를 내보낸다. 이 방식은 2008년 GDC 발표가 정리했고, iWave·eWave 같은 격자 기법, 유니티 바다 도구 Crest의 동적 파동, 언리얼 나이아가라의 얕은 물 시뮬레이션이 같은 계열이다. 격자를 캐릭터 둘레에만 두고 따라다니게 하면 위 눈 발자국처럼 넓은 물에서도 칸 수가 그대로다.

말리는 파도: 게르스트너 파도와 그려 둔 단면

넓은 바다는 오래 높이 지도로 그렸다. 격자 칸마다 높이 하나를 올리고 내린다. 그런데 해변의 파도는 앞으로 넘어간 마루 끝(입술) 밑에 속이 빈 관을 만든다. 이 관을 세로로 자르면 한 자리에 수면이 세 번 지나가니, 높이 하나로는 나타낼 수 없다.

높이장과 말린 파도 단면의 차이: 높이장은 가로 위치마다 높이가 하나뿐이라 세로선이 수면을 한 번 지나고, 손으로 그린 말린 단면은 입술 윗면, 입술 밑면, 앞쪽 물에서 세 번 지나므로 높이 하나로는 나타낼 수 없다높이장: x마다 높이 하나1세로선이 수면을 한 번만 지난다그려 둔 단면: 말린 입술123세로선이 수면을 세 번 지난다
높이장은 가로 위치마다 높이가 하나라 세로선이 수면을 한 번 지나고, 말린 파도의 단면은 입술 윗면·밑면·앞 물에서 세 번 지난다.

게르스트너 파도(Gerstner wave)는 수면의 점을 위아래로만이 아니라 원을 그리며 돌린다. 쉬는 자리 x₀의 점이 위상 φ = k·x₀ − ωt에서 수평으로 −A·sin φ, 수직으로 A·cos φ만큼 움직이고(깊은 물에서 ω² = g·k), 여러 방향의 파도를 더해 바다를 만든다. 점이 마루 쪽으로 모여 마루는 뾰족하고 골은 넓어진다. 그러나 가파르기 k·A가 1이면 마루가 뾰족한 점이 되고, 1을 넘으면 면이 고리로 꼬여 안팎이 뒤집힌다. 이 고리는 말린 관이 아니고 모양을 고를 수도 없다.

그래서 말리는 파도는 식으로 만들지 않고 그려 둔다. 파도의 일생(너울 → 일어섬 → 입술 → 관 → 흰 물)을 단면 곡선 몇 단계로 아티스트가 만들고, 텍스처에 굽는다. 정점마다 "파도 안에서의 위치"와 "해변까지 얼마나 왔나(진행 단계)"로 그 표를 읽어 옆과 위로 민다. 평평하던 면이 몇 배로 늘어나므로 파도 둘레는 테셀레이션을 아주 촘촘히 한다. 라이엇의 2XKO 해변 무대는 시뮬레이션 없이 메시 하나의 버텍스 셰이더로 말리는 모양을 빚었고(Unreal Fest 발표), 게릴라의 호라이즌 포비든 웨스트는 아티스트가 정한 파도 모양을 긴 해안선에 늘어놓았다고 발표했다(SIGGRAPH 2022).

어디서 부서질지도 사람이 정한다. 해변까지의 거리를 진행 단계로 바꾸는 곡선으로 천천히 쌓이다 빠르게 접히게 하고, 부서지는 자리를 곡선으로 그려 둔다. 파도 줄을 그 선에 비스듬히(각 α) 놓으면 부서짐이 한쪽 끝에서 줄을 따라 번지는데, 번지는 속도는 파도 속도 ÷ sin α라 파도 자체보다 빠르다. 현실에서는 바닥이 정한다. 얕아지면 파도가 느려지고(얕은 물 파속 √(g·d)) 뒤 파도가 따라붙어 높아지다가, 파고가 수심의 약 0.78배(이론값, 실제는 해변 경사에 따라 다르다)를 넘으면 부서진다. 게임은 이 계산 대신 보기 좋은 자리를 고른다.

말린 파도는 높이 하나로 물어볼 수 없어서, 배나 캐릭터가 그 파도와 만나는 계산은 따로 만들어야 한다.

넓은 바다: 파도 스펙트럼과 FFT

게르스트너 파도 몇 개를 더해 만든 바다는 오래 보면 같은 줄무늬가 되풀이되는 것이 보인다. 진짜 먼바다의 수면은 방향과 파장이 다른 파도 수만 개의 합이다. 그래서 영화와 게임의 바다는 파도 하나하나를 고르는 대신 "어느 방향, 어느 파장의 파도가 얼마나 센가"를 적은 스펙트럼(spectrum)에서 출발한다.

  1. 스펙트럼 고르기: 바람의 속도와 방향을 넣으면 파수 벡터(파도의 방향과 촘촘함)마다 평균 세기가 나오는 해양학 식(필립스 스펙트럼, JONSWAP 등)을 쓴다. 바람이 셀수록 긴 파도가 강해지고, 바람과 직각으로 가는 파도는 약하다.
  2. 무작위로 흩기: 격자 칸마다 정규분포 난수를 곱해 세기와 시작 위상을 흩는다. 시드가 같으면 같은 바다가 나온다.
  3. 시간 돌리기: 깊은 물에서는 ω = √(g·k)라(위 게르스트너 식과 같다) 칸마다 위상을 그 속도로 돌린다. 긴 파도는 빨리 달리고 잔물결은 느리게 따라온다.
  4. 역 FFT(fast Fourier transform): 파도 목록 격자를 높이 격자로 바꾼다. N × N 칸마다 N × N개 파도를 직접 더하면 N⁴번 계산이지만 FFT는 N² log N 정도라, 256 × 256이면 수천 배 빠르다. 매 프레임 GPU 컴퓨트 셰이더로 돌린다.

나온 높이장은 위아래·좌우 끝이 이어지는 타일이라 바다 전체에 깔 수 있지만, 멀리서 보면 같은 무늬가 바둑판처럼 드러난다. 그래서 수백 m, 수십 m, 몇 m처럼 크기가 다른 타일 몇 장을 겹쳐 반복 주기를 숨긴다. 높이만 바꾸면 마루가 둥글게 남는데, 같은 FFT로 수평 변위까지 구해 점을 마루 쪽으로 몰면 뾰족해진다. 너무 몰아 면이 접히기 직전인 곳, 곧 변위가 면을 얼마나 줄였나를 나타내는 값(야코비안)이 문턱 아래로 떨어지는 곳이 마침 부서지는 마루라 그 자리에 거품을 칠하고 조금씩 사라지게 남긴다.

깊은 물을 가정한 식이라 해안에서 말려 부서지는 파도는 위 절처럼 따로 만든다. 배의 부력처럼 CPU가 수면 높이를 물어야 하는 경우에는 GPU 결과를 늦게 읽어 오거나, 필요한 몇 점만 CPU에서 같은 파도 합으로 다시 계산한다. 스펙트럼에서 거품까지 직접 만들어 보는 과정은 영상 I Tried Simulating The Entire Ocean과 영상 How Games Fake Water가 잘 보여 주고, 사인파에서 게르스트너 파도까지 유니티로 짜 보는 Waves 튜토리얼도 있다.

잉크 얼룩: 표면마다 든 렌더 타깃과 시드

스플래툰처럼 칠한 넓이를 겨루는 게임에서 잉크는 그림이면서 데이터다. 예뻐야 하고, 얼마나 칠했는지 셀 수 있어야 하고, 발밑이 우리 팀 색인지 물을 수 있어야 한다. 덧붙이는 그림 조각인 데칼(decal)은 보이는 일만 잘한다. 개수가 늘수록 그리기 비용이 오르고, 넓이는 따로 계산해야 한다.

  • 표면이 든 그림: 바닥·벽·상자 면마다 렌더 타깃을 하나씩 두고 거기에 얼룩을 찍는다. 재질은 월드 좌표를 한 축 방향으로 눌러 그림 속 좌표로 바꾸고, 거기서 읽은 값으로 바탕색과 팀 색을 섞는다. 찍는 코드와 읽는 셰이더가 반드시 같은 투영식을 써야 어긋나지 않는다. 팀마다 다른 채널(빨강·초록)에 찍으면 세기도 쉽다.
  • 모서리 잇기: 터진 자리에서 곧은 광선만 쏘면 상자 옆면이 덜 젖고 윗면의 잉크가 모서리 밖 허공으로 빠진다. 면을 따라 걸어 모서리에서 꺾어 가며 찍을 자리를 찾으면 얼룩이 모서리를 돌아 이어진다.
  • 그럴듯한 모양: 액체가 부딪힌 자국은 동그라미가 아니다. 퍼지던 테두리가 불안정해져 손가락처럼 갈라지고, 손가락 끝이 끊겨 방울로 튄다. 비스듬히 부딪힌 방울은 타원으로 남는데 짧은 축 ÷ 긴 축이 충돌각의 사인값이다(법의학 혈흔 분석에서 쓰는 관계). 마른 얼룩은 테두리가 진하다(커피 자국 효과). 무작위를 빼면 곧바로 도장처럼 보인다.

그런데 칠한 넓이가 점수라면 얼룩이 모든 기기에서 같아야 한다. 그래서 폭발마다 정수 시드 하나로 난수 발생기를 초기화해 방울의 각도·거리·크기를 차례로 뽑는다. 같은 시드면 같은 순서로 같은 값이 나와 같은 얼룩이 찍히니, 네트워크로는 위치와 정수 하나만 보내면 된다. 그 대신 계산이 기기마다 똑같아야 하는 조건이 붙는다(멀티플레이 노트의 결정성).

넓이는 그림의 픽셀을 세서 구한다. GPU의 텍스처를 CPU로 읽어 오는 일은 비싸고 기다림이 생기므로, 몇 프레임에 한 번만 세거나, GPU에서 줄인 그림(밉맵)으로 평균을 낸 뒤 작은 결과만 비동기로 읽는다. 그리기 비용은 얼룩 수와 상관없이 표면 수만큼 텍스처를 읽는 것으로 고정된다. 곡면이 많은 물체는 UV를 제대로 펴거나 세 방향 투영 (triplanar) 같은 방법이 필요하다.

빛줄기: 볼류메트릭 안개

창으로 들어오는 빛줄기는 공기 속 먼지와 물방울이 빛을 옆으로 흩뿌려 빛이 지나는 길 자체가 보이는 현상이다. 게임 렌더러는 기본적으로 표면만 그려서 아무것도 없는 공기 한가운데는 그릴 대상이 없다. 그래서 공기를 따로 적분한다.

원리는 레이 마칭(ray marching)이다. 픽셀마다 카메라 광선 위에 점을 촘촘히 찍고, 점마다 그 자리에 빛이 닿는지 표면 그림자에 쓰는 섀도 맵으로 판정한다. 닿으면 그 자리의 밀도 × 산란 정도 × 보는 각도에 따른 몫(위상 함수)만큼 빛을 더하고, 지나온 공기만큼 빛이 줄어드는 투과율 T = exp(−σ × 거리)를 곱해 간다(Beer–Lambert 법칙). 창살에 가린 점은 꺼지니 창살 모양이 공기 속에 그대로 선다.

볼류메트릭 안개의 레이 마칭: 카메라 광선을 여러 걸음으로 나눠 걸음마다 햇빛이 닿는지 그림자로 판정하고 닿는 걸음만 밝기를 더한다. 실제 구현은 절두체를 칸으로 나눠 칸마다 한 번만 판정하고, 칸이 성기면 빛줄기 가장자리가 칸째 번진다광선 위를 걷는다창과 창살카메라● 해가 보이는 걸음만 밝기를 더한다칸마다 한 번 판정한다칸이 성기면 빛 띠(점선) 밖으로 번진다왼쪽은 원리, 오른쪽은 실제 구현의 모양 · 칸을 키울수록 싸고 뭉개진다
왼쪽은 원리: 광선 위의 걸음 가운데 창을 통해 해가 보이는 걸음만 밝기를 더한다. 오른쪽은 실제 구현: 절두체를 칸으로 나눠 칸마다 한 번 판정하고, 칸이 성기면 빛 띠가 칸째 번진다.

예전에는 화면 공간에서 흉내 냈다. 다 그린 화면에서 밝은 픽셀을 골라 화면 속 태양 위치를 향해 방사형으로 번지게 하는 후처리다. 싸고 예쁘지만, 카메라를 돌려 태양이 화면 밖으로 나가면 번질 기준점이 사라져 줄기도 사라진다.

픽셀마다 수십 걸음씩 그림자 판정을 하면 화면 전체로는 감당이 안 된다. 그래서 지금 엔진은 카메라 절두체를 가로세로 몇 픽셀 단위, 깊이 방향으로는 멀수록 두껍게 나눈 격자(froxel, frustum voxel)에 먼저 쌓는다. 칸마다 밀도와 조명을 한 번 계산하고 앞에서 뒤로 적분해 3D 텍스처로 남기면, 화면의 픽셀은 물론 반투명 물체까지 그 텍스처를 읽기만 한다. 지터를 주고 지난 프레임과 섞어 성긴 격자의 계단을 숨긴다. 어쌔신 크리드 4(2014 발표), 프로스트바이트(2015 발표), 언리얼의 Volumetric Fog가 이 구조다. 격자가 성길수록 싸고 줄기 가장자리가 뭉개진다. 비용은 그림자를 드리우는 광원 수와 안개가 닿는 거리에 크게 달려 있어서, 실무에서는 전역 안개를 옅게 깔고 원하는 광원만 산란 세기를 올린다.

우주까지 이어지는 하늘: 대기 산란과 레이 마칭

게임 하늘은 흔히 스카이박스(skybox)로 그린다. 레벨을 둘러싼 상자에 하늘 그림을 입히고 카메라를 따라다니게 하면 아주 멀리 있는 것처럼 보인다. 대신 하늘이 보는 방향으로만 정해져서, 높이 올라가도 하늘은 파랗고 땅은 평평한 판으로 작아질 뿐이다. 땅에서 우주까지 끊김 없이 오르려면 하늘을 카메라 자리에서 계산해야 한다.

픽셀마다 카메라 광선이 공기층을 지나는 구간을 걸음으로 나누고, 걸음마다 세 값을 곱해 더한다. 해에서 그 자리까지 남은 햇빛(해 쪽으로 다시 몇 걸음 걸어 구한다), 그 자리의 공기가 빛을 카메라 쪽으로 튕기는 몫, 그 자리에서 카메라까지 오며 남는 몫이다.

  • 레일리 산란(Rayleigh): 공기 분자가 흩는 빛. 파장의 네제곱에 반비례해 파랑이 많이 흩어져 낮 하늘이 파랗고, 빛이 공기를 오래 지나는 해 질 녘은 붉다.
  • 미 산란(Mie): 먼지·물방울 같은 알갱이가 흩는 빛. 색을 거의 가리지 않고 앞쪽으로 몰려 해 둘레가 하얗게 번진다.
  • 공기 밀도는 높이에 따라 지수로 준다. 분자는 약 8km, 알갱이는 약 1.2km 올라갈 때마다 1/e로 주는 값이 흔히 쓰인다. 올라갈수록 더할 빛이 줄어 하늘이 까매진다.

땅도 공으로 놓아야 한다. 행성 반지름 R, 카메라 높이 h에서 지평선은 수평보다 arccos(R ÷ (R + h))만큼 내려가고 휘어 보인다. 광선이 땅에 닿으면 그 색 위에 사이 공기가 보탠 빛을 얹는데, 먼 산이 푸르게 흐려지는 대기 원근(aerial perspective)이 이것이다.

픽셀마다 다 걸으면 비싸서, 엔진은 계산을 작은 표(LUT) 몇 장으로 나눈다. 투과율 표, 여러 번 튕긴 빛(다중 산란) 표, 방향마다 하늘빛을 담은 하늘 표, 거리별 대기 원근을 담은 3D 표이고, 카메라 자리가 바뀔 때마다 이 작은 표만 다시 그린 뒤 화면은 읽기만 한다. 2020년 논문으로 정리되어 언리얼 엔진의 Sky Atmosphere가 이 방식이고, 그 전에는 2008년의 사전 계산 대기 산란이 널리 쓰였다. 하늘 표가 거의 빈 우주를 담게 되는 높은 곳에서는 다시 픽셀마다 걷는다.

우주 규모에서는 좌표도 문제다. 32비트 부동소수점은 값이 클수록 표현 간격이 넓어져, 행성 중심에서 6,000km 넘게 떨어지면 간격이 수십 cm가 된다. 그래서 큰 좌표는 64비트로 다루거나(언리얼 엔진 5의 대형 월드 좌표), 카메라 가까이를 원점으로 옮겨 계산한다. 먼 땅은 절차 생성 노이즈를 거리에 따라 겹 수를 줄여(LOD) 지평선까지 잇는다(절차 생성 노트).

불: 그리지 않고 돌리는 유체 시뮬레이션

게임 속 모닥불은 대개 미리 그린 그림이다. 타는 모습 수십 장을 한 텍스처에 모은 플립북(flipbook)을 카메라를 늘 마주 보는 판(빌보드)에 넘기거나, 텍스처 여러 장을 곱하고 서로 다른 속도로 흘리는 셰이더로 만든다. 싸고 예쁘지만 불꽃은 정해진 그림이라 도끼가 지나가도 바뀌지 않는다. 볼륨 유체는 아직 게임에 널리 쓰기엔 비싸다는 평가가 많고, 빛을 여러 방향에서 미리 구운 연기 텍스처 같은 절충이 쓰인다.

불을 계산하려면 불이 타는 공간을 3D 칸으로 나누고 칸마다 바람(속도)·열(온도)·연료·연기를 적는다. 한 스텝은 1999년 Stable Fluids 논문의 뼈대를 따른다.

  1. 힘: 뜨거운 칸에 위로 뜨는 힘(부력)과 작은 흔들림을 주고, 계산을 거듭하며 뭉개지는 작은 소용돌이를 되살리는 힘(vorticity confinement)을 더한다.
  2. 이류(advection): 칸마다 바람을 거슬러 거꾸로 따라가 그 자리의 값을 보간해 가져온다(semi-Lagrangian). 시간 간격이 커도 터지지 않는 것이 장점이고, 대신 조금씩 뭉개진다.
  3. 투영(projection): 옮기고 나면 공기가 한쪽에 몰리거나 빈 곳이 생긴다. 압력 방정식을 이웃끼리 맞추는 반복(야코비 등)으로 풀어 몰리지도 비지도 않는 바람(발산 0)에 가깝게 고친다.
  4. 연소: 고친 바람으로 열·연료·연기를 옮기고, 온도 문턱을 넘은 연료를 열과 연기로 바꾸며, 열은 조금씩 식힌다.

그리기는 결과를 볼륨 텍스처에 담아 레이 마칭으로 한다. 걸음마다 온도에 맞는 불꽃 색(흑체 색 띠)을 더하고, 연기는 뒤를 가리며 빛을 받는다. 화면의 불은 따로 그린 그림이 아니라 칸의 온도 그 자체다. 그래서 상호작용이 저절로 생긴다. 물체가 차지한 칸을 고체 칸으로 바꾸고 그 칸의 바람을 물체 속도로 두면, 공이나 도끼가 불을 쓸고 지나가고 냄비 밑에 부딪힌 불은 옆으로 퍼져 냄비를 타고 오른다. 물체 쪽으로 파고드는 바람 성분만 지우고 겉면을 따라 미끄러지는 성분은 남겨야 자연스럽다. 둘레를 비추는 불빛도 뜨거운 칸의 무게 중심과 합으로 자리와 세기를 정하면 불을 따라 움직인다.

비용은 칸 수에 달려 있다. 해상도를 가로·세로·높이 두 배로 올리면 칸은 여덟 배가 되고 압력 반복도 더 필요해 금세 한 프레임을 넘는다. 값을 16비트로 줄이거나, 연기가 든 블록만 계산하는 희소(sparse) 시뮬레이션을 쓰지만, 불은 둘레까지 열과 연기가 퍼져 있어 건너뛸 블록이 생각보다 적을 수 있다. 게임 쪽은 범위를 줄인다. 불을 2D로 계산해 카메라 쪽으로 세우거나, 플레이어 둘레의 바람만 작은 3D 격자로 돌려 파티클과 나뭇잎을 흔든다(갓 오브 워, GDC 2019). 언리얼 엔진의 나이아가라 플루이드도 3D 템플릿을 실시간에서는 주인공 효과용으로 소개한다.

연막: 공간을 채운 복셀

카운터스트라이크 2의 연막은 열린 문과 계단을 따라 번지고, 총알과 수류탄이 그 모양을 밀고 깎는다. 밸브는 이를 게임 세계에 자리하는 볼류메트릭 오브젝트라고 소개했지만 구현은 밝히지 않았다. 아래는 그런 동작을 내는 한 가지 방법이다.

  • 막히지 않은 이웃으로 번지기: 공간을 작은 정육면체 칸(복셀, voxel)으로 나누고 벽·계단·문짝이 차지한 칸을 막힌 칸으로 표시한다. 터진 자리에서 길로 잰 거리가 가까운 칸부터(다익스트라 알고리즘) 정해 둔 칸 수만큼 채운다. 길은 막힌 칸을 비켜서만 이어지니 벽을 돌고 계단을 오르는 모양이 따로 짜지 않아도 나온다. 대각선으로 건널 때는 사이 칸이 열려 있어야 벽의 모서리 틈으로 새지 않는다.
  • 부피는 정해져 있다: 채울 칸 수가 같으니 넓은 마당에서는 둥글게, 좁은 복도에서는 길게 퍼진다. 거리만으로 줄을 세우면 반구가 되므로 공간에 고정된 노이즈로 칸마다 길 값을 흔들어 울퉁불퉁하게 만든다. 닫힌 문은 막힌 칸이라 문이 열리면 기다리던 양이 문 밖으로 샌다.
  • 총알과 수류탄: 총알 선이 지나는 칸을 3D 격자 순회(1987년 Amanatides–Woo 알고리즘: 축마다 다음 칸 경계까지 남은 거리를 재 두고 가장 가까운 쪽으로 한 칸씩 넘어간다)로 차례로 찾아 둘레까지 비운다. 수류탄은 반경 안을 비우되 터진 자리에서 보이지 않는 벽 너머 칸은 건드리지 않는다. 비운 칸은 잠시 뒤 이웃부터 다시 차고, 비운 양은 가장자리로 돌려 총량을 지킨다.

보이는 연기도 같은 복셀로 그린다. 칸 값(차 있음·비어 있음)을 시간에 따라 부드럽게 따라가게 하고 한 번 흐린 뒤 3D 텍스처로 올려, 레이 마칭으로 Beer–Lambert 투과율을 쌓는다. 복셀 경계가 각져 보이지 않게 가장자리는 노이즈로 깎고 시간에 따라 흔든다. 연기가 없는 곳은 큰 걸음으로 건너뛰고, 연기만 절반 해상도로 그려 키우면 비용이 크게 준다. 시야를 가리는 연기는 판정에 직결되므로, 모든 플레이어가 같은 모양을 보도록 서버가 정한 상태를 따르거나 결정적으로 계산해야 한다.

바람: 공기가 아니라 낙엽이 읽는 값

많은 게임에서 높은 벽 뒤의 낙엽도 벽 앞 낙엽과 똑같이 날아간다. 언리얼의 Wind Directional Source, 유니티의 윈드 존(Wind Zone)은 공기 흐름을 풀지 않는다. 그저 방향과 세기의 값을 내놓고, 천·나무·파티클이 저마다 그 값을 읽어 흔들린다. 바람이 벽에 막히는지 따지는 단계가 어디에도 없다.

  • 위치마다 박자 늦추기: 값이 하나면 돌풍이 어디서나 같은 박자로 와 들판이 한꺼번에 들썩인다. 바람 방향으로 갈수록 돌풍 박자(위상)를 조금씩 늦추면 들썩임이 바람을 따라 훑고 지나간다. 계산은 위치만큼 위상을 미는 것뿐이다(렌더링 노트의 풀과 같은 원리).
  • 점 바람: 폭발 같은 바람은 반경 안의 위치에 중심에서 바깥쪽 방향을 돌려주고, 거리에 따라 1 − (거리 ÷ 반경)² 같은 식으로 줄인다. 포인트 라이트의 감쇠를 흉내 낸 꼴이고, 여기도 벽은 확인하지 않는다.

벽에 막히는 바람이 필요하면 무엇을 더할지 고른다.

방식하는 일되는 것안 되는 것
값 하나위치를 넘기면 방향·세기를 돌려준다가장 싸다벽을 모른다
바람 그림자섀도 맵처럼 바람 쪽(또는 폭발점)에서 방향마다 벽까지의 거리를 맵에 적고, 그보다 멀면 바람을 줄인다벽 뒤가 잠잠하다. 바람 방향이 바뀔 때만 다시 만든다막기만 하고 벽을 돌아 흐르지 않는다
베이크한 흐름장미리 유체를 계산해 칸마다 평균 바람을 저장하고 보간해 읽는다벽을 돌아 흐르고 벽 뒤에서 말려 도는 바람(와류)구운 방향의 바람에만 맞는다
실시간 시뮬레이션플레이어 둘레의 작은 3D 격자에서 바람을 계산한다휘두른 무기나 폭발이 바람을 만든다. 장애물을 넣으면 막힌다비용과 범위 제한

갓 오브 워(2018)는 플레이어 둘레의 작은 격자에서 바람을 계산해 파티클·나뭇잎·머리카락을 흔들고 도끼질도 바람을 만들게 했다(Wind Simulation in God of War). 리터널(2021)은 미리 구운 흐름장 대신 플레이어 둘레에서 실시간 유체를 돌리고, 적이 내려찍는 순간처럼 게임 속 사건이 그 유체에 힘을 넣어 주변 낙엽과 불똥이 튀게 했다고 개발 블로그에서 밝혔다.

만화처럼 칠하기: 툰 셰이딩과 외곽선

셀 애니메이션은 그늘을 두세 단계의 평평한 색으로 칠하고 윤곽에 선을 긋는다. 3D로 이것을 흉내 내는 툰 셰이딩(toon shading, cel shading)은 조명 계산의 마지막 한 단계만 바꾼다.

  • 램프(ramp): 면이 빛을 얼마나 정면으로 받는지(노멀과 빛 방향의 내적 N·L)를 밝기로 그대로 쓰지 않고, 1D 텍스처나 계단 함수로 몇 단계 색에 대응시킨다. 경계를 1~2픽셀만 부드럽게 섞으면 계단 테두리가 지글거리지 않는다. 그늘 칸을 회색 대신 차가운 파랑으로 두는 식으로 램프 색이 곧 그림 스타일이 된다.
  • 하이라이트와 림: 반사광도 문턱으로 잘라 가장자리가 딱 떨어지는 점으로 만들고, 시선과 거의 직각인 면(가장자리)에 테두리 빛(rim light)을 얹는다.
  • 노멀 손보기: 얼굴처럼 그늘 모양이 그림의 인상을 정하는 곳은 실제 형태의 노멀 대신 아티스트가 고친 노멀을 쓴다. 코 밑과 볼에 지저분한 그늘 조각이 생기지 않고, 원화에서처럼 한 번에 넘어가는 그늘을 얻는다.
툰 셰이딩의 램프: 빛과 면의 각도에 따라 밝기가 부드럽게 변하는 램버트 곡선 대신, 경계마다 밝기가 한 칸씩 뛰는 계단 램프를 쓰면 공이 세 가지 색 띠로 칠해지고, 법선 방향으로 부풀린 뒷면을 어둡게 그리면 둘레에 외곽선이 생긴다10밝기N·L (빛을 정면으로 받을수록 1)점선: 램버트실선: 계단 램프같은 램프로 칠한 공외곽선외곽선: 법선 방향으로 부풀린 뒷면을 어둡게 그린다 · 경계 값은 예시
툰 셰이딩의 램프. 점선의 부드러운 밝기 대신 계단 램프를 쓰면 공이 세 가지 색 띠로 칠해지고, 부풀린 뒷면을 어둡게 그려 둘레에 외곽선을 얻는다.

외곽선은 크게 두 방식이다.

  • 뒤집은 껍질(inverted hull): 메시를 한 번 더 그리되 정점을 노멀 방향으로 조금 밀고, 앞면을 버리고 뒷면만 어두운 색으로 칠한다. 본체 둘레로 삐져나온 뒷면이 선이 된다. 미는 양을 카메라 거리에 비례해 늘리면 화면에서 굵기가 일정하다. 모서리가 각진 메시는 꼭짓점의 노멀이 갈라져 선이 끊기므로, 부드럽게 평균 낸 노멀을 따로 저장해 밀 때만 쓴다.
  • 후처리 테두리: 다 그린 화면의 깊이·노멀 버퍼에서 이웃 픽셀과 값이 크게 다른 곳(소벨 같은 경계 검출 필터)을 선으로 칠한다. 비용이 물체 수가 아니라 화면 크기로 정해지고 안쪽 주름까지 잡지만, 물체마다 선 굵기나 색을 다르게 주기는 어렵다.

둘을 섞는 게임도 많다. 캐릭터 둘레는 껍질로 굵게, 배경과 안쪽 선은 후처리로 얇게 긋는 식이다. 얇은 선은 TAA를 거치며 흐려지거나 깜빡일 수 있어, 선을 그리는 순서와 굵기를 해상도에 맞춰 조정한다.

노이즈로 움직이는 효과: 디졸브, 흐름 지도, 도메인 워핑

타들어 가며 사라지는 몸, 일렁이는 용암, 소용돌이치는 마법진 같은 효과의 상당수는 시뮬레이션이 아니라 노이즈 텍스처 한두 장과 시간이다. 노이즈를 만드는 법(격자와 옥타브)은 절차 생성 노트에 있고, 여기서는 그 값을 효과에 쓰는 법을 본다.

  • 디졸브(dissolve): 픽셀마다 노이즈 값 n과 진행도 t를 비교해 n < t인 픽셀을 버린다. t를 0에서 1로 올리면 얼룩지며 사라진다. n이 t보다 조금만 큰 띠를 밝은 주황으로 칠하면 타들어 가는 가장자리가 되고, 노이즈에 높이 값을 섞으면 발끝부터 사라진다.
  • 흘리고 비틀기: 노이즈를 시간에 따라 흘려 화면 UV를 조금씩 흔들면 아지랑이와 물속 굴절이 된다. 방향이 다른 노이즈 두 장을 곱하면 한쪽으로 미끄러지는 티가 덜 난다.
  • 흐름 지도(flow map): 텍스처 칸마다 흐를 방향을 적어 두고 UV를 그 방향으로 밀면 강물이 바위를 돌아 흐른다. 계속 밀면 무늬가 늘어져 망가지므로 일정 주기로 처음 자리로 되돌리는데, 반 주기 어긋난 두 벌을 만들어 한쪽이 되돌아가는 순간에는 다른 쪽만 보이게 섞으면 이음매가 감춰진다. 유니티로 직접 따라 하는 Texture Distortion 튜토리얼이 있다.
  • 도메인 워핑(domain warping): 옥타브를 겹친 노이즈(fBm, fractional Brownian motion)를 읽을 좌표에 다른 노이즈 값을 더해 비튼다. 무늬가 감기고 늘어져 연기, 대리석, 가스 행성 같은 소용돌이가 나오고, 비트는 쪽 노이즈를 시간으로 움직이면 흐른다.

노이즈는 셰이더 안에서 해시로 바로 계산하거나 미리 구운 타일 텍스처를 읽는다. 텍스처를 읽는 쪽이 대개 싸고, 계산하는 쪽은 해상도에 끝이 없다. 노이즈와 fBm을 셰이더로 직접 그려 보는 The Book of Shaders의 노이즈 장과 fBm 장, 겹치는 옥타브의 성질을 다룬 fBm 글과 도메인 워핑 글이 좋다.

더 읽을거리