포스트

구면 조화 함수 — 방향에 따라 달라지는 색을 몇 개의 숫자로

구면 조화 함수 — 방향에 따라 달라지는 색을 몇 개의 숫자로

서론

앞 글에서 최종 색이 이렇게 나온다는 것까지 왔습니다.

\[C = \sum_i T_i\,\alpha_i\, c_i\]

$T_i$ 와 $\alpha_i$ 는 다뤘습니다. 남은 건 $c_i$ — 그 항의 색입니다.

색을 그냥 상수 하나(RGB 세 개)로 두면 안 될까요. 됩니다. 그리고 그건 MVS 글에서 봤던 램버시안 가정과 정확히 같은 선택입니다. 어느 방향에서 봐도 같은 색이라는 뜻이죠.

그러면 젖은 바닥의 반사도, 금속의 광택도, 유리창에 비친 하늘도 표현할 수 없습니다. 그리고 그런 것들이야말로 사진을 사진처럼 보이게 만드는 요소입니다.

그래서 색을 방향의 함수 $c(\mathbf{d})$ 로 둡니다. 문제는 그걸 어떻게 저장하느냐입니다. 3DGS는 가우시안 하나마다 이 함수를 들고 있어야 하고, 가우시안은 수백만 개입니다. 함수 하나를 적은 수의 숫자로, 빠르게 평가할 수 있게 담아야 합니다.

그 표준 답이 구면 조화 함수(spherical harmonics, SH) 입니다.


1. 원 위의 푸리에 급수에서 출발하기

낯선 이름이지만 발상은 익숙합니다. 1차원 주기함수를 사인·코사인으로 펼치는 게 푸리에 급수입니다.

\[f(\theta) = a_0 + \sum_{k=1}^{\infty} \big(a_k \cos k\theta + b_k \sin k\theta\big)\]

여기엔 좋은 성질이 여럿 있습니다. 기저가 직교하니 계수를 내적 하나로 구할 수 있고, $k$ 가 작은 항일수록 큰 그림을 담으므로 앞에서 잘라도 그럴듯한 근사가 됩니다.

구면 조화 함수는 이 이야기를 구 위에서 하는 것입니다. 정의역이 원 $S^1$ 이 아니라 구 $S^2$ 일 뿐, 하는 일은 똑같습니다.

\[f(\mathbf{d}) \;\approx\; \sum_{\ell=0}^{L}\sum_{m=-\ell}^{\ell} c_{\ell m}\, Y_{\ell m}(\mathbf{d})\]

$\ell$ 이 주파수에 해당합니다. $\ell$ 이 작으면 구 전체에 걸친 완만한 변화, 크면 잘게 쪼개진 방향 구조입니다. 그리고 각 $\ell$ 마다 $m$ 이 $-\ell$ 부터 $\ell$ 까지 $2\ell+1$ 개 있습니다.

구면 조화 함수 기저 $\ell = 0$ 은 방향에 무관한 상수 하나. $\ell = 1$ 은 $x, y, z$ 세 방향의 기울기. $\ell$ 이 올라갈수록 구가 잘게 나뉜다. 각 행의 개수가 $2\ell+1$ 이므로, $\ell \le L$ 까지 쓰면 계수는 $\sum_{\ell=0}^{L}(2\ell+1) = (L+1)^2$ 개다.

왜 하필 이 함수들인가. SH는 구면 위의 라플라스 방정식을 각 부분과 지름 부분으로 분리했을 때 나오는 각 부분의 해입니다. 물리에서는 전자 궤도의 모양(s, p, d 오비탈)으로 먼저 만나는 그것이고, 위 그림의 두 번째·세 번째 행이 정확히 p 오비탈, d 오비탈의 모양입니다.1 같은 함수가 양자역학과 렌더링에 동시에 등장하는 것은, 둘 다 “구면 위의 함수를 주파수로 분해한다”는 같은 일을 하기 때문입니다.


2. 계수 구하기는 내적 하나

기저가 직교정규이므로,

\[\int_{S^2} Y_{i}(\mathbf{d})\, Y_{j}(\mathbf{d})\, d\omega = \delta_{ij}\]

계수는 그냥 목표 함수와의 내적입니다.

\[c_i = \int_{S^2} f(\mathbf{d})\, Y_i(\mathbf{d})\, d\omega\]

구면 적분은 방향을 균등하게 뿌려 평균 내면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import numpy as np
from scipy.special import lpmv, factorial

def K(l, m):
    m = abs(m)
    return np.sqrt((2*l + 1) / (4*np.pi) * factorial(l - m) / factorial(l + m))

def Y(l, m, theta, phi):
    """실수 구면 조화 함수. theta: 극각, phi: 방위각."""
    x = np.cos(theta)
    if m == 0:
        return K(l, 0) * lpmv(0, l, x)
    if m > 0:
        return np.sqrt(2) * K(l, m) * np.cos(m * phi) * lpmv(m, l, x)
    return np.sqrt(2) * K(l, -m) * np.sin(-m * phi) * lpmv(-m, l, x)

def project(f, L, n=200_000, seed=0):
    """구면 위 함수 f 를 차수 L 까지의 SH 계수로 투영한다."""
    rng = np.random.default_rng(seed)
    v = rng.normal(size=(n, 3))
    v /= np.linalg.norm(v, axis=1, keepdims=True)      # 구면 균등 표본
    theta = np.arccos(np.clip(v[:, 2], -1, 1))
    phi = np.arctan2(v[:, 1], v[:, 0])

    vals = f(v)
    return np.array([(vals * Y(l, m, theta, phi)).mean() * 4*np.pi
                     for l in range(L + 1) for m in range(-l, l + 1)])

직교성 덕분에 계수를 하나씩 독립적으로 구할 수 있다는 점이 중요합니다. 차수를 3에서 4로 올려도 이미 구한 계수는 그대로이고 새 항만 추가됩니다. 연립방정식을 다시 풀 필요가 없습니다.


3. 어디까지 올려야 하나

여기가 실전 판단이 필요한 지점입니다.

SH 차수에 따른 근사 정확도 확산광에 좁은 반사 하이라이트가 얹힌 함수를 SH로 근사한 것. $\ell \le 1$(계수 4개)까지는 하이라이트가 아예 안 보이고, $\ell \le 3$(16개)에서 겨우 뭉툭한 얼룩으로 나타난다. 오차는 차수를 올릴수록 꾸준히 줄지만 뚝 떨어지는 지점이 없다.

이 그림이 SH의 성격을 정직하게 보여 줍니다.

  • 낮은 주파수는 아주 적은 계수로 잘 담습니다. 넓게 퍼진 확산광은 $\ell \le 2$(9개)면 충분합니다. 실제로 게임에서 환경광을 저장할 때 9개짜리 SH를 쓰는 관행이 여기서 나왔습니다.2
  • 좁은 하이라이트는 잘 못 담습니다. 뾰족한 봉우리를 저주파 기저로 만들려면 항이 아주 많이 필요합니다. 1차원 푸리에에서 사각파를 근사할 때 나타나는 그 현상과 같습니다.
  • 그리고 잘라 낸 근사는 음수가 될 수 있습니다. 원래 함수가 어디서나 양수여도 그렇습니다(깁스 현상). 색이 음수가 되면 렌더링에서는 그냥 0으로 자릅니다.

3DGS의 선택은 $\ell \le 3$ 입니다. 계수 16개 × RGB 3채널 = 48개. 이게 왜 이 값인지는 메모리를 계산해 보면 명확합니다.

가우시안 하나가 저장하는 것float 개수
위치 $\mu$3
스케일 + 회전3 + 4
불투명도1
SH 계수48
합계59

전체의 80%가 색입니다. 가우시안 100만 개면 약 236MB이고, 그중 192MB가 SH입니다. $\ell$ 을 4로 올리면 계수가 25개 × 3 = 75개가 되어 메모리가 1.4배로 뜁니다. 그림에서 보듯 그 대가로 얻는 화질 개선은 크지 않습니다.

학습할 때는 차수를 천천히 올립니다. 3DGS 구현은 처음에 $\ell = 0$(상수 색)만 학습하고, 일정 반복마다 차수를 하나씩 열어 줍니다. 이유는 이렇습니다. 처음부터 고차 항을 풀어 주면, 기하가 아직 엉망인 상태에서 색이 시점별 오차를 흡수해 버립니다. 가우시안이 엉뚱한 자리에 있어도 “이 방향에서는 이 색, 저 방향에서는 저 색”으로 맞춰 버리면 손실이 줄어드니까요. 저차부터 열면 그런 도피처가 없어서 기하를 고치는 쪽으로 기울기가 흐릅니다.3


4. SH가 특별한 진짜 이유 — 회전

여기까지는 “구면 위의 푸리에”라는 말로 충분합니다. 그런데 SH에는 다른 기저에 없는 성질이 하나 더 있습니다.

함수를 회전시키면, 같은 차수 $\ell$ 안의 계수들끼리만 섞입니다. 차수를 넘나드는 섞임은 일어나지 않습니다.

\[\mathcal{R}\left[\sum_m c_{\ell m} Y_{\ell m}\right] = \sum_m c'_{\ell m} Y_{\ell m}, \qquad c' = D^{(\ell)} c\]

각 차수가 회전에 대해 닫혀 있다는 뜻입니다(군론 용어로는 기약 표현). 실용적으로 이게 왜 중요하냐면,

  • 조명을 회전해도 각 차수의 에너지가 보존됩니다. 그래서 “이 환경광은 $\ell \le 2$ 로 충분하다”는 판단이 조명 방향과 무관하게 유효합니다.
  • 차수별로 잘라도 회전 대칭이 깨지지 않습니다. $\ell \le 3$ 까지 자른 근사는 어느 방향으로 회전시켜도 여전히 $\ell \le 3$ 근사입니다.

만약 기저를 아무렇게나 골랐다면, 특정 방향에서만 잘 맞고 회전하면 무너지는 근사가 됐을 것입니다. 좌표계에 의존하지 않는 근사라는 점 — 이것이 SH가 렌더링의 표준이 된 이유입니다.4


5. 3DGS는 이걸 어떻게 쓰나

렌더링 한 번에서 일어나는 일은 세 줄입니다.

1
2
3
4
5
6
7
8
9
def gaussian_color(sh_coeffs, mu, cam_center, L=3):
    """sh_coeffs: (16, 3). 가우시안 중심에서 카메라를 향하는 방향으로 평가한다."""
    d = mu - cam_center
    d = d / np.linalg.norm(d)
    theta = np.arccos(np.clip(d[2], -1, 1))
    phi = np.arctan2(d[1], d[0])

    Yv = np.array([Y(l, m, theta, phi) for l in range(L+1) for m in range(-l, l+1)])
    return np.clip(Yv @ sh_coeffs + 0.5, 0.0, None)     # 0.5 를 더해 [0,1] 중앙에 맞춤

몇 가지 짚을 점이 있습니다.

방향을 픽셀이 아니라 가우시안 단위로 정합니다. 엄밀하게는 픽셀마다 시선 방향이 조금씩 다르지만, 3DGS는 가우시안 중심에서 카메라 중심으로 가는 방향 하나로 그 가우시안 전체의 색을 정합니다. 가우시안 하나가 화면에서 차지하는 크기가 작으니 오차가 작고, 무엇보다 가우시안당 한 번만 평가하면 되어 훨씬 쌉니다.

$\ell = 0$ 항이 기본 색입니다. $Y_{00} = 1/(2\sqrt{\pi}) \approx 0.2821$ 로 상수이므로, 이 항 하나가 “방향에 무관한 색”을 담습니다. 구현에서 결과에 $0.5$ 를 더하는 것은 SH 계수를 0 근처에서 출발시켜 학습을 안정화하기 위한 관례입니다.

SH 계수도 Adam으로 학습됩니다. 그런데 SH 계수의 기울기 크기는 위치나 불투명도의 기울기와 자릿수가 다릅니다. Adam이 각 파라미터를 자기 기울기 크기로 나눠 주지 않았다면, 이 48개를 위치와 같은 옵티마이저로 굴리는 건 불가능했을 것입니다.


6. 자주 나오는 질문들

Q1. 차수를 충분히 올리면 어떤 함수든 표현되나?

이론적으로는 그렇습니다. SH는 구면 위 제곱적분가능 함수의 완비 기저입니다. 문제는 속도입니다. 좁은 하이라이트를 제대로 담으려면 $\ell$ 이 수십은 돼야 하고, 계수는 $(\ell+1)^2$ 로 늘어납니다. $\ell = 20$ 이면 441개 × 3채널이라 가우시안 하나가 5KB를 넘습니다. 현실적이지 않습니다.

Q2. 그럼 거울 같은 표면은 3DGS로 못 그리나?

$\ell \le 3$ 로 담을 수 있는 건 완만한 시점 의존성까지입니다. 선명한 거울 반사는 담기지 않고, 대신 다른 방식으로 “설명”됩니다 — 반사상이 있어야 할 자리에 가짜 기하(floater) 를 만들어 버리는 것입니다. 특정 시점에서는 그럴듯해 보이지만 카메라를 움직이면 허공에 뜬 덩어리가 드러납니다. 3DGS 결과에서 흔히 보이는 실패 유형이고, 원인은 표현력이 부족한 곳을 기하가 대신 떠맡기 때문입니다.

Q3. SH 말고 다른 선택지는?

몇 가지가 실제로 쓰입니다.

  • 구면 가우시안(spherical Gaussian) — 방향에 뾰족한 로브를 직접 두는 방식. 하이라이트에 강하지만 회전 성질이 SH만큼 깔끔하지 않습니다.
  • 작은 MLP — NeRF가 하는 방식입니다. 방향을 입력으로 받아 색을 뱉습니다. 표현력은 가장 좋지만 MLP 호출 비용이 붙습니다.
  • 아무것도 안 쓰기 — 색을 상수로 두는 것. 실제로 3DGS를 가볍게 만든 변형들이 이 선택을 합니다. 메모리의 80%가 날아가니까요.

SH는 이 중 평가 비용과 표현력의 절충에서 좋은 자리에 있습니다. 행렬-벡터 곱 하나면 끝이니까요.

Q4. 뷰 방향을 $\mu - C$ 로 잡으면 가우시안이 클 때 부정확하지 않나?

부정확합니다. 화면에서 크게 보이는 가우시안(가깝거나 큰 것)일수록 픽셀마다 실제 시선 방향이 크게 다른데, 하나의 방향으로 뭉뚱그리기 때문입니다. 실용적으로 문제가 잘 안 되는 이유는, 학습이 진행되면서 큰 가우시안이 잘게 쪼개지기 때문입니다. 밀도 조정이 이 오차도 함께 줄여 주는 셈입니다.


마치며

  • 색을 상수로 두는 것은 램버시안 가정이고, 그러면 광택·반사가 전부 사라진다.
  • 방향의 함수를 담는 표준 도구가 구면 조화 함수 — 구 위의 푸리에 급수다.
  • 직교정규 기저라서 계수는 내적 하나로 구해지고, 차수를 올려도 앞의 계수는 그대로다.
  • 저주파는 싸게, 고주파는 아주 비싸게 담긴다. 좁은 하이라이트는 SH의 약점이다.
  • 3DGS는 $\ell \le 3$, 즉 채널당 16개 × 3 = 48개를 쓴다. 가우시안 저장공간의 80%가 이것이다.
  • 회전해도 차수가 섞이지 않는다는 성질이 SH를 렌더링의 표준으로 만들었다.

이제 표현할 것은 다 갖췄습니다. 기하(가우시안), 합성(알파 블렌딩), 색(SH). 남은 질문은 하나입니다. 이 전부를 사진에 맞춰 학습하려면, 렌더러 자체가 미분 가능해야 합니다. 그게 다음 글입니다.


이 시리즈 — 3DGS를 읽기 위한 기초지식

  1. MLP와 역전파
  2. SGD에서 Adam까지
  3. SfM 파이프라인
  4. MVS — 밀집 복원
  5. 래스터화
  6. 알파 블렌딩과 볼륨 렌더링
  7. 구면 조화 함수 ← 지금 읽는 글
  8. 미분 가능 렌더링
  9. NeRF
  10. 3D Gaussian Splatting ← 도착지

참고문헌

  1. Green, R. (2003). Spherical harmonic lighting: The gritty details. GDC. — 그래픽스 쪽 SH 입문으로 가장 널리 읽히는 문서. ↩︎

  2. Ramamoorthi, R., & Hanrahan, P. (2001). An efficient representation for irradiance environment maps. SIGGRAPH ‘01, 497–500. — 확산 조명은 SH 9개면 평균 1% 오차라는 그 결과. ↩︎

  3. Kerbl, B., Kopanas, G., Leimkühler, T., & Drettakis, G. (2023). 3D Gaussian Splatting for Real-Time Radiance Field Rendering. ACM TOG, 42(4). — SH 차수를 점진적으로 여는 학습 방식은 5.1절과 공식 구현에 있다. ↩︎

  4. Sloan, P.-P., Kautz, J., & Snyder, J. (2002). Precomputed radiance transfer for real-time rendering in dynamic, low-frequency lighting environments. ACM TOG, 21(3). — SH가 실시간 렌더링에 자리 잡은 계기. ↩︎

이 기사는 저작권자의 CC BY-NC-ND 4.0 라이센스를 따릅니다.

License

CC BY-NC-ND 4.0

이 포스트는 CC BY-NC-ND 4.0 라이선스로 제공됩니다. 출처 표시는 필요하며, 상업적 이용과 변경은 허용되지 않습니다.