TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] 뉴럴 렌더링 탐험기

jplee 2026. 2. 19. 02:00

원문

 

Adventures in Neural Rendering

In recent years, neural networks have started to find their way into many areas of rendering. While antialiasing and upscaling are probably the most well‑known uses, they’re far from the only ones—…

interplayoflight.wordpress.com


최근 몇 년 사이, 신경망(Neural Network)은 렌더링의 여러 분야에 스며들기 시작했다. 안티에일리어싱이나 업스케일링이 가장 널리 알려진 사례이긴 하지만, 그게 전부가 아니다—텍스처 압축, 머티리얼 표현, 간접 조명 모두 현재 활발히 연구되고 개발 중인 영역이다.

나는 최근 신경망을 직접 만지작거려 보기 시작했고, 렌더링 맥락에서 데이터를 인코딩하는 수단으로 소규모 다층 퍼셉트론(MLP, Multilayer Perceptron)을 실험해 봤다. 이 글에서는 그 과정을 정리하고, 그래픽스 프로그래머 시각에서 바라본 초기 결과와 관찰 내용을 공유한다(신경망에 대한 사전 경험은 거의 없다).

시작에 앞서 한 가지 짚고 넘어가자면, 이 글은 MLP에 대한 튜토리얼이 아니다. 신경망은 가장 단순한 형태에서도 꽤 복잡한 주제이고, 입문용으로 좋은 자료가 이미 많다. 입문용으로 다음 두 가지를 추천한다: Machine Learning for Game DevelopersCrash Course in Deep Learning. 이 글에서는 참고용으로 몇 가지 개념만 간략히 요약한다.

시각적 참고를 위해, 단순한 MLP가 어떻게 생겼는지 아래에 나타냈다:

이 경우 네트워크는 입력 노드 3개, 각각 3개의 노드로 이루어진 은닉층(hidden layer) 2개, 그리고 출력 노드 1개로 구성된다(앞으로는 이 MLP를 3-3-3-1 표기법으로 부를 것이다). 중간 레이어를 "은닉(hidden)"이라 부르는 이유는, 우리가 직접 개입하지 않기 때문이다—입력 데이터를 넣고 출력 데이터를 관찰할 뿐이다. 또한 이 예시에서는 특정 노드 수를 사용했지만, 각 레이어의 노드 수에는 메모리와 처리 시간 외에는 제한이 없다. 레이어의 노드 수가 중요한 이유는, 각 노드가 이전 레이어의 모든 노드를 처리하기 때문이다(즉, 그래프는 완전 연결(fully connected) 상태다). 예를 들어 은닉층 1의 Node 0에 집중해 보면:

이 노드는 입력 노드 3개를 조합하여 다음과 같이 출력을 생성한다(다음 레이어로 전달됨):

Output_{node_0} =I_0 * w_0 + I_1 * w_1 + I_2 * w_2 + bias_{node_0}

노드 0의 출력값은 간단히 말해, 모든 입력 노드 출력의 편향된 가중합(biased weighted sum)이다. 이 값을 다음 레이어에 전달하기 전에 "활성화(activation)" 함수를 통과시켜야 한다. 활성화 함수는 해당 값에 특정 연산을 수행하는데, 가장 널리 사용되는 것 중 하나가 음수 값을 제거하는 ReLU다:

ReLU(x) = max(0, x)

그리고 그 변형인:

\text{LeakyReLU}(x) = \begin{cases} x, & x \ge 0, \\ \alpha x, & x < 0. \end{cases}

작은 alpha 값(예: 0.01)을 사용한 버전이다. 이 버전은 일부 음수 출력을 유지하며, 학습 속도가 더 빠르다는 것을 직접 확인했다. 신경망의 활성화 함수 선택에는 다양한 옵션이 있으며, 학습률과 수렴에 각기 다른 영향을 미친다. ReLU와 LeakyReLU는 무난한 시작점이고, 이 글에서 설명하는 실험에는 LeakyReLU를 사용했다.

저장 요구사항으로 돌아가서, 위 MLP의 가중치와 편향을 float 타입으로 저장한다고 가정하면: 첫 번째 은닉층은 노드당 입력 가중치 3개와 편향 1개(3×3+3 floats), 두 번째 은닉층도 마찬가지, 출력층은 1×3+1 floats—총 28개의 float이 필요하다. 이 수치는 금방 커질 수 있다. 예를 들어 입력 노드 9개, 은닉층 3개(각 64 노드), 출력 노드 3개짜리 MLP라면 9,155개의 float이 필요하다. fp16이나 더 낮은 데이터 타입을 사용하면 이를 줄일 수 있다.

위에서 설명한 대로 노드를 순차적으로 조합하여 출력을 생성하는 MLP 구현은 매우 단순하다. 까다로운 것은 가중치와 편향을 산출하는 부분이며, 이것이 바로 신경망 훈련이 필요한 이유다. 훈련의 깊은 내용은 이 글의 범위를 벗어나며, 앞서 언급했듯 좋은 튜토리얼이 많다. 맥락을 위해 간략히 설명하자면: 임의의 가중치와 편향으로 시작하여 입력 벡터를 주면 네트워크의 출력을 계산한다(순전파, forward propagation, 즉 inference). 물론 출력은 틀릴 것이므로, "손실(loss)" 함수를 이용해 얼마나 틀렸는지 그래디언트(gradient, 차이)를 계산한다(훈련 중에는 입력과 기대 정답 출력을 모두 알고 있어야 한다). 이 그래디언트를 역방향으로 네트워크에 전달해 가중치와 편향을 조정한다(역전파, back propagation). 조정 후 새 입력 벡터(또는 동일 입력)를 다시 넣고 출력을 계산하여, 정답과의 차이를 구하고 다시 역전파한다. 이 과정을 수없이 반복하면, 최종적으로 계산된 출력이 기대 출력에 가까워지게 된다—즉, 네트워크가 입출력 매핑을 "학습"한 것이다.

구현 측면에서, 나는 입력층과 출력층을 포함해 최대 5개 레이어(은닉층 최대 3개)의 MLP를 가정했다. 가중치와 편향은 2개의 ByteAddressBuffer에 저장했다. 순전파와 역전파 모두 루프가 많기 때문에, 컴파일러를 돕기 위해 이 포스트와 유사하게 레이어 수와 레이어당 노드 수를 정적으로 정의하여 동적 루프를 피했다. 코드를 많이 넣지는 않을 것이므로, 좋은 샘플이 포함된 위 블로그 포스트를 참고하길 권한다. 예시로, 레이어 인덱스, 노드 인덱스, 요소(가중치 또는 편향) 인덱스를 기반으로 가중치·편향 버퍼의 인덱스를 반환하는 코드는 다음과 같다:

순전파(inference) 코드는 앞서 언급했듯 매우 단순하며, 레이어, 레이어의 노드, 노드의 입력을 순회하는 중첩 루프 3개로 구성된다.

#define MAX_LAYER_COUNT 5
 
static const uint neuronsPerLayer[MAX_LAYER_COUNT] =
{
    LAYER0_NEURON_COUNT, LAYER1_NEURON_COUNT, LAYER2_NEURON_COUNT, LAYER3_NEURON_COUNT, LAYER4_NEURON_COUNT
};
 
static const uint weightOffsetsPerLayer[MAX_LAYER_COUNT] =
{
    LAYER0_WEIGHT_OFFSET, LAYER1_WEIGHT_OFFSET, LAYER2_WEIGHT_OFFSET, LAYER3_WEIGHT_OFFSET, LAYER4_WEIGHT_OFFSET
};
 
static const uint biasOffsetsPerLayer[MAX_LAYER_COUNT] =
{
    LAYER0_BIAS_OFFSET, LAYER1_BIAS_OFFSET, LAYER2_BIAS_OFFSET, LAYER3_BIAS_OFFSET, LAYER4_BIAS_OFFSET
};
 
static const uint neuronOffsetsPerLayer[MAX_LAYER_COUNT] =
{
    LAYER0_NEURON_OFFSET, LAYER1_NEURON_OFFSET, LAYER2_NEURON_OFFSET, LAYER3_NEURON_OFFSET, LAYER4_NEURON_OFFSET
};
 
uint GetNeuronCount(uint layer)
{
    return neuronsPerLayer[layer];
}
 
uint GetWeightIndex(uint layer, uint neuronIndex, uint weightIndex)
{
    return weightOffsetsPerLayer[layer] + neuronIndex * neuronsPerLayer[layer-1] + weightIndex;
}
 
uint GetBiasIndex(uint layer, uint neuronIndex)
{
    return biasOffsetsPerLayer[layer] + neuronIndex;
}
 
uint GetNeuronIndex(uint layer, uint index)
{
    return neuronOffsetsPerLayer[layer] + index;
}
void ForwardPass(inout float inputs[LAYER0_NEURON_COUNT], inout float nodeOutputs[MAX_NOOF_NODES])
{
    uint outputIndex = 0;
     
    //입력 레이어
    for (uint index = 0; index < GetNeuronCount(0); index++)
    {
        nodeOutputs[outputIndex++] = inputs[index];
    }
     
    //나머지 레이어들
    for (uint layer = 1; layer < LAYER_COUNT; layer++)
    {
        for (uint index = 0; index < GetNeuronCount(layer); index++)
        {
            float output = GetBias(layer, index);
     
            for (int i = 0; i < GetNeuronCount(layer-1); i++)
            {
                float weight = GetWeight(layer, index, i);
                float previousLayerOut = nodeOutputs[GetNeuronIndex(layer - 1, i)];
                 
                output += weight * previousLayerOut;
            }
         
            nodeOutputs[outputIndex++] = ActivationFunction(output);
        }
    }
}

훈련 단계도 대체로 위 포스트를 따랐으며, 수렴 개선을 위해 Adam 최적화도 구현했다.

MLP 구현을 마치고 나니, 그래픽스 맥락에서 어디에 활용할 수 있을지 궁금해졌다. 나의 접근 방식은 다소 단순했다—레이어와 노드 수가 적은 소형 MLP에 집중했고, 모든 레이어에 동일한 활성화 함수를 사용했는데, 이는 최상의 결과를 얻는 방법은 아닐 것이다. MLP의 출력은 레이어 수, 레이어당 노드 수, 활성화 함수(레이어마다 다를 수도 있음), 손실 함수 등에 따라 달라지며, 좋은 결과를 얻으려면 이 모든 것을 실험해봐야 한다—훈련 비용 때문에 시간이 꽤 걸린다.

MLP의 흥미로운 특성 중 하나는, 구면 조화(Spherical Harmonics)나 팔면체 표현(octahedral representation)처럼 정보/신호를 인코딩할 수 있다는 점이다. 단, 분석적 방식이 아닌, 입력을 기반으로 기대 출력을 "학습"하는 비분석적 방식이다. 예시로, 법선 방향을 따라 큐브맵의 Radiance를 인코딩해봤다. 입력 레이어 노드 3개(법선 xyz), 은닉층 3개 노드 1개, 출력 레이어 3개 노드(radiance rgb)로 이루어진 최소 MLP를 사용했다.

비교를 위해 L2 구면 조화(Spherical Harmonics) 큐브맵 Radiance 인코딩(이 훌륭한 라이브러리 사용)도 사용했다:

SH 근사는 Radiance 방향성의 대략적인 감을 주지만 매우 거칠다. 반면 위에서 설명한 MLP는 다음과 같은 출력을 낸다:

이 경우 방향성이 훨씬 향상되었으며, 출력은 큐브맵의 매우 저해상도 버전처럼 보인다. 더 흥미로운 점은, L2 SH 표현은 계수 저장에 27개의 float(float3 9개)이 필요한 반면, 이 MLP는 24개만으로 훨씬 향상된 품질을 달성한다는 것이다.

MLP를 더 작게 만들 수 있을까? 은닉층을 노드 2개로 줄이면 어떻게 될까?

그렇다면 노드 1개로 줄이면?

노드 2개일 때는 방향성을 대체로 유지하지만 바람직하지 않은 색조 편이(colour shift)가 발생한다. 노드 1개일 때는 L2 SH 표현의 거친 수준에 더 가까워지는데, 겨우 10개의 float 저장으로 인상적이지만 색조 편이 문제로 Radiance 인코딩 용도로는 사용 불가다.

Irradiance도 MLP로 인코딩할 수 있는 방향성 데이터다. 은닉층 1개, 노드 3개인 NN의 출력은 다음과 같다:

비교를 위한 L2 구면 조화 버전:

그리고 셰이더에서 큐브맵을 몬테카를로 적분하는 "Ground Truth" 버전:

참고로 MLP는 Ground Truth Irradiance 계산 방법의 출력으로 훈련됐다. MLP 출력은 Ground Truth에 꽤 근접하지만, SH만큼 가깝지는 않다. 위 시나리오에서는 SH가 Irradiance를 약간 더 잘 인코딩하는 것으로 보인다.

은닉층 노드가 1개인 더 작은 MLP는 Irradiance의 방향성을 잘 포착하지 못한다.

조명 기법을 매끄러운 구(sphere)만으로 평가해서는 안 된다—노멀맵을 적용하면 숨어있던 문제가 명확히 드러나기 때문이다. 이를 위해 구에 노멀맵을 추가하고 약간 확대하여 각 Irradiance 인코딩 방식의 결과를 다시 확인해보자.

소형 MLP(은닉층 1개, 노드 3개)의 출력:

L2 SH 출력:

Ground Truth 출력:

이번에도 구면 조화와 Ground Truth 출력은 꽤 유사하지만, MLP 인코딩의 차이가 더 두드러진다. 이 소형 MLP는 Irradiance 방향성을 잘 인코딩하지 못해, 바닥에서 반사된 빛이 벽돌 면에 "누수(leaking)"되는 현상이 보인다.

결론적으로, MLP에서 SH와 유사한 응답을 얻으려면 은닉층 2개, 각 4개의 노드가 필요하다:

이런 MLP를 저장하려면 51개의 부동소수점 숫자가 필요한데, L2 SH의 27개보다 꽤 많다. 적어도 이 맥락에서는, 저장 측면에서 L2 SH와 유사한 수준의 MLP가 Irradiance보다 Radiance를 더 잘 인코딩하는 것으로 보인다.

또 다른 실험으로, 특정 월드 포지션을 중심으로 구 위의 여러 방향에 대한 깊이(depth)를 인코딩해봤다(depth 큐브맵으로도 할 수 있는 작업이다). Ground Truth는 레이 트레이싱으로 구했다:

구 위에 분포된 벡터를 입력으로 사용한 3-3-3-1 MLP의 출력은 다음과 같다:

너무 거칠어서 실용적이지 않다. 은닉층을 3-32-32-32-1로 늘리면 출력에서 특징(feature)이 보이기 시작한다:

그리고 최종적으로 3-128-128-128-1로 늘리면:

출력에 훨씬 더 많은 디테일이 보이며 사용 가능한 수준에 근접했다. 이 크기의 MLP는 저장에 약 33,665개의 부동소수점 숫자—134KB—가 필요하다. 비교로, 작은 128x128x6 depth 큐브맵은 약 393KB다. 그러나 MLP inference 비용이 매우 높아서 컴퓨트 셰이더 구현 기준으로는 실용성이 없다(3080 모바일 GPU에서 44ms).

또 다른 실험으로, MLP를 RTAO 캐시로 활용할 수 있는지 테스트했다. 월드 포지션과 법선을 입력으로 사용하는 6-32-32-32-1 NN이다(40ms):

6-64-64-64-1로 확장하면(240ms):

MLP는 특정 뷰의 월드 포지션에서 AO를 포착하는 것을 어느 정도 해내지만, inference 비용이 매우 크다. 또한 훈련 시간 때문에 여러 뷰를 학습시키지는 못했기 때문에, 씬 전체를 학습하기에 얼마나 적합한지는 불분명하다. 예를 들어, 카메라를 다른 뷰로 이동하여 AO를 학습시킨 후 원래 뷰로 돌아오면 MLP가 이전 AO를 잘 기억하지 못한다.

MLP가 씬 전체의 AO를 정확히 표현할 수 있는지 확신하기 어렵다. 그렇게 하려면 훨씬 더 많은 훈련과 잠재적으로 훨씬 더 큰 네트워크가 필요할 것으로 추측된다. 그렇다 하더라도 inference 비용이 실용성을 떨어뜨린다—적어도 컴퓨트 셰이더 구현 기준에서는.

마지막 테스트로, Specular BRDF(Cook-Torrance)를 인코딩해봤다. 법선, 라이트 방향, 뷰 방향, F0, 러프니스를 입력(총 13개)으로 MLP에 제공했다. 입력이 무작위로 선택되더라도 유효하지 않은 조합(예: 수평선 아래의 라이트 방향)을 줄이기 위해 라이트 방향과 뷰 방향은 법선 중심의 반구로 제한했다.

결과적으로 MLP는 상대적으로 큰 모델인 13-128-128-128-3에서도 BRDF 근사에 크게 어려움을 겪었다:

참조 출력과 비교하면:

F0와 러프니스를 제거하여 입력을 줄였을 때도 마찬가지였다. MLP(적어도 그 크기와 훈련량에서는)가 스페큘러 로브(specular lobe)를 포착하는 데 어려움을 겪는 것으로 보이며, 특히 낮은 러프니스 값에서 두드러진다.

BRDF에는 Rusinkiewicz 파라미터화라는 다른 파라미터화 방식이 있다. 뉴럴 BRDF 구현에서 널리 사용되며, 차원 변동을 줄이고 스페큘러 로브 표현을 개선할 수 있다. 간략히 말하면, 이 방법은 BRDF를 기존의 법선 벡터 기준(BRDF 각도의 원점이 법선 벡터)에서 하프 벡터(half vector) 기준으로 재파라미터화한다.

결과적으로 스페큘러 로브는 theta_d 값에 관계없이 주로 theta_h 축에 위치하게 된다. 또한 내가 사용하는 것 같은 등방성(isotropic) BRDF에서는 phi_h가 0이 되어 입력 크기를 더 줄일 수 있다.

theta_h, theta_d, phi_d—3개의 각도를 입력으로 Rusinkiewicz 파라미터화를 사용하면, 훨씬 작은 MLP인 3-64-64-64-3으로도 스페큘러 로브를 훨씬 잘 표현할 수 있다(출력 품질은 추가 훈련 시간으로 더 향상될 것이다):

더 작은 3-32-32-3도 어느 정도의 정확도로 스페큘러 로브를 포착하는 것으로 보이지만, 훈련 시간이 크게 늘어나고 약간의 양자화(quantisation)가 눈에 띈다.

위 두 예시는 고정된 러프니스와 F0를 가정하기 때문에 단일 머티리얼에만 적합한 MLP다. F0와 러프니스를 다시 도입하면 로브 포착 능력이 저하된다—적어도 내가 허용한 훈련 시간 내에서는, 이 추가 입력 없이 했을 때보다 훨씬 긴 시간이 소요됐음에도 불구하고.

정리하자면: 신경망, 적어도 MLP는 구현은 비교적 쉽지만 유용한 결과를 내게 하기가 까다롭다. 그래픽스 프로그래머는 시스템을 미세 조정하고 더 나은 결과를 얻기 위해 파라미터를 조율하는 데 익숙하지만, 이 분야는 나에게 새로운 영역이라 MLP의 다양한 파라미터—노드 수 대 레이어 수, 어떤 활성화 함수가 더 좋은 이유—가 미치는 영향을 아직 충분히 파악하지 못했다고 느낀다. 훈련 시간도 중요한 요소인데, MLP를 수정하면 특히 큰 네트워크에서 결과를 확인하는 데 오랜 시간이 걸리며, inference 비용도 상당히 높아 실시간 렌더링 응용에 제약이 될 수 있다. 그럼에도 이 분야는 신호를 인코딩/표현하는 수단으로서 가능성을 보여주는 흥미로운 영역이라고 생각하며, 곧 지원될 예정인 HLSL의 inference 가속 기능이 그 비용을 크게 낮출 잠재력이 있다고 본다.