볼류메트릭 레이 마처(Volumetric Ray Marcher) 제작하기 (1부)



볼류메트릭 레이 마처(Volumetric Ray Marcher) 제작하기 (1부)

Volumetric Ray Marcher(볼류메트릭 레이 마처) 제작하기



저의 지난 포스트들은 볼류메트릭(체적 측정) 레이 마칭(진행,앞으로 나아가기) 쉐이더를 만드는 데 필요한 요소들을 다루고 있었습니다. 그것들을 간단하게 리뷰해보겠습니다.

원문 : https://shaderbits.com/blog/creating-volumetric-ray-marcher/

번역 : http://cafe.naver.com/unrealfx (금별)



볼륨(volume,체적,부피) 텍스처 생성:
이 포스트에서는 플립북과 같이, 텍스처 슬라이스(slice)들을 2d 프레임들처럼 바르게 나열하여 가상 볼륨 텍스처를 생성하는 방법을 살펴보았습니다. 이번 포스트에서 텍스쳐를 인코딩하고 디코딩하는 데에도 같은 방법이 사용될 것입니다.
http://shaderbits.com/blog/authoring-pseudo-volume-textures

레이 마칭 하이트맵(heightmap):

이 포스트는 2d 표면 평면 내에서 볼류메트릭 쉐도우(volumetric shadow)를 렌더링하는 방법을 설명합니다. 표면 자체는 시야각에 기초한 어떠한 종류의 시차도 없는 2d 평면이었습니다. 이번에는 완전한 볼류메트릭 이펙트를 내기 위해 밀도 추적(density trace) 내에서 그림자 추적(shadow trace)을 할 것입니다.
http://shaderbits.com/blog/ray-marched-heightmaps

사용자 지정 오브젝트당 그림자:

이 포스트는 빛의 시각에서 뎁스맵(depth map)를 저장함으로써 오브젝트별로 그림자가 생성될 수 있음을 보여 줍니다. 여기에서도 동일한 기법을 사용하여 환경 음영(environmental shadowing) 옵션을 추가할 것입니다.
http://shaderbits.com/blog/custom-per-object-shadowmaps-using-blueprints

이것이 우리의 목표입니다: 



 



볼륨 레이마칭


부피 측정(volumetric) 렌더링의 기본 개념은 볼륨을 통과하는 광선(ray)의 값을 측정하는 것입니다. 이는 일반적으로 볼륨을 가로지르는 각 픽셀에 대해 불투명도(opacity)와 색상을 반환하는 것을 의미합니다. 볼륨이 분석 함수(analytical function)인 경우 직접 결과를 계산할 수 있지만, 볼륨이 텍스쳐에 저장되어 있는 경우에는 볼륨을 통과하는 스텝들을 밟아나가야 하며, 한 스텝 나아갈 때마다 텍스처를 찾아보아야 합니다. 이것은 두 부분으로 나뉠 수 있습니다:

1) 불투명도(빛 흡수)
2) 색상(illumination, 산란(Scattering))

불투명도 샘플링(Opacity Sampling)

볼륨의 불투명도를 생성하려면, 관찰 가능한 각 지점의 밀도 또는 두께를 알아야 합니다. 일정한 밀도 및 색상을 지닌 볼륨인 경우, 필요한 것은 불투명 오클루더(occluder)에 도달하기 전 각 광선(ray)의 총 길이일 뿐입니다. 텍스처를 입히지 않은 단순한 안개의 경우, 이는 표준 함수: D3DFOG_EXP를 사용하여 다시 매핑되는 Scene Depth(씬 깊이)일 뿐입니다. 이 함수(function)는 다음과 같이 정의됩니다:


F = 1/ e ^(t * d).

여기서 t는 일부 매체를 통해 이동하는 거리이고 d는 매체의 밀도입니다. 이는 그동안 게임에서 얼마나 값싸게 unlit fog를 계산해 왔는지를 보여줍니다. 이것은 Beer-Lambert law에서 나온 것으로, 입자(particle)들이 모여 만든 볼륨이 지닌 투과율(transmittance)을 다음과 같이 정의합니다:


Transmittance = e ^ (-t * d).


비슷해 보일 것입니다. 실제로 같기 때문입니다. x^(-y)는 1/(x^y)과 동일합니다. 지수함수적 안개는 Beer-Lambert 법칙의 응용 버전일 뿐입니다. 이러한 함수(function)가 어떻게 체적 측정(volumetrics)에 적용되는지 이해하기 위해, Drebin[1]의 오래된 논문에서 발췌한 하나의 방정식을 살펴보도록 하겠습니다. 이는 빛이 복셀(voxel)을 통과할 때 얼마나 많은 광선이 빛의 방향으로 빠져나가는지를 나타냅니다. 정확한 컬러를 반환하여, 모든 복셀이 고유의 색을 지니도록 설계된 식입니다:

Cout(v) = Cin(v) * (1 - Opacity(x)) + Color(x) * Opacity(x)

Cin(v)은 복셀을 통과하기 전 빛의 색이고, Cout(v)은 복셀을 통과한 후의 색입니다. 이 식은 빛의 광선이 볼륨을 통과할 때, 모든 복셀에서, 먼저 흡수(absorption)를 시뮬레이션하기 위하여 빛의 색은 현재의 복셀이 지닌 불투명도의 역에 곱해지게 되고, 현재 복셀의 색에 현재 복셀의 불투명도를 곱한 값이 더해져 산란(scattering)이 시뮬레이션될 것임을 말하고 있습니다. 볼륨이 앞면으로 다시 추적(trace)되는 한, 이 코드는 이대로도 작동될 수 있습니다. 1로 초기화된 Transmitance(투과율) 변수를 추적하면, 볼륨이 어느 방향으로든 추적될 수 있습니다. 투과율은 불투명도의 역으로 간주될 수 있습니다.


여기서 Exp(지수함수), 즉 e^x 함수가 작동합니다. 은행 계좌 이자의 문제와 유사하게, 계좌에 이자가 더 자주 적용될수록, 더 많은 돈을 벌 수 있지만, 오직 일정 수준까지만 가능할 것입니다. 그 지점이 e에 의해 규정됩니다. 일정 볼륨 내의 밀도를 적분한 결과를 비교할 때도 동일한 효과가 나타납니다. 스텝이 더 늘어나면, 최종 결과는 Exp 또는 e의 거듭제곱으로 정의된 함수의 해에 더 많이 수렴됩니다. 이것이 Beer-Lambert Law와 D3DFOG_EXP 함수가 사용되게 된 이유입니다.


지금까지 살펴본 수학은 사용자 지정 볼륨 렌더러를 구축하는 방법에 대한 몇 가지 힌트를 제공합니다. 우리는 각 지점에서 볼륨의 두께를 알아내야 한다는 것을 알고 있습니다. 그런 다음 이 두께 값은 지수 밀도 함수와 함께 사용되어 볼륨이 차단할 빛의 양을 근사적으로 추정할 수 있습니다.

볼륨의 밀도를 샘플링하기 위해 볼륨을 통과하는 각 레이들을 따라 스텝을 밟게됩니다. 그리고 각 지점에서는 볼륨 텍스처의 값이 읽혀질 것입니다. 이 예는 가상의 구의 볼륨 텍스처입니다. 카메라 광선(ray)은 체적(볼륨)을 균일 간격으로 샘플링하여 매체 내에서 이동하는 거리를 측정한 결과를 보여줍니다:


(Camera Rays카메라 광선, Ray Samples outside media매체 밖 광선 샘플, Ray Samples inside media 매체 안 광선 샘플, Distance Traveled within media 매체 내에서 이동한 거리)

스텝 중에 광선이 매체 내부에 있으면, 누적 변수(variable)에 스텝 길이가 추가됩니다. 레이가 미디어 외부에 있는 경우 스텝 도중 아무 것도 누적되지 않습니다. 최종적으로, 각 픽셀에 대해, 볼륨 텍스처 내에서 매체 내부에 있는 동안 카메라 레이가 얼마나 멀리 이동했는지 설명해주는 값을 얻게 됩니다. 거리(distance)는 각 지점(point)들에서 불투명도에 곱해지기 때문에, 반환되는 최종 거리는 선형 밀도(Linear Density)를 나타내게 됩니다.

이 거리는 위의 예에서 노란색 점들 사이의 노란색 선으로 표시되어 있습니다. 위의 예와 같이 낮은 스텝 카운트가 사용되는 경우, 거리가 실제와 일치하지 않을 수 있으며, 절단 아티팩트가 나타나게 됩니다. 이러한 종류의 아티팩트 및 솔루션에 대해서는 이후에 자세히 설명하도록 하겠습니다.


이 시점에서 우리는 선형 값들을 누적시키고, 마지막에 선형 거리를 반환하고 있을 뿐입니다. 볼류메트릭(volumetric)한 모습이 나타날 수 있도록, 지수 함수를 사용하여 최종 값을 다시 매핑합니다. 위에서 언급된 표준 Direct3D 지수 안개 함수 D3DFOG_EXP가 이에 적합합니다.


불투명도 전용(Opacity-Only) 레이 마치의 예


사용자 지정 노드에서 모든 레이 마칭 코드를 수행할 수 있지만, 그 경우 여러 개의 커스텀 노드가 요구되는 중첩 함수(nested function) 호출(call)이 필요하게 됩니다. 커스텀 노드는 자동으로 이름이 지정됩니다. 즉, 컴파일러가 그것들을 추가하는 순서를 알고 있다는 가정 하에 그것들을 호출해야 함을 뜻합니다(예: CustomExpression0, 1, 2...). 새로운 것들이 추가되거나 혹은 다양한 머티리얼 핀들 사이에서 그것들이 이어지는 방식이 변경되는 것만으로도, 컴파일러는 함수들의 이름을 다시 짓기 시작할 수 있습니다.

이 부분을 좀 더 쉽게 만들기 위해 common.usf 파일에 PsuedoVolumeTexture(가상의 볼륨 텍스처) 함수를 추가하였습니다. 다운로드하여 Engine\Shadder에 있는 common.usf를 덮어씁니다. 에디터가 실행 중인 상태에서 할 수 있으며, 바로 적용될 것입니다. 이는 유사(pseudo) 볼륨 텍스처에 관한 이전 게시물에서 사용되었던 코드입니다. 이 함수를 사용하면 Raymarching 코드가 크게 간소화되며, 향후 ue4의 새 버전이 지원할 때에는 표준 3D 텍스쳐 샘플용으로 사용될 수도 있습니다. 아래 버전 중 하나가 없는 경우, 하나를 다운로드하고 마지막 두 함수(function)를 여러분의 버전에 복사하십시오. 4.14.1 버전이 출시될 때까지 4.13.2를 사용할 것을 권장합니다. 이는 마지막 부분에서 다시 다루도록 하겠습니다.


common.usf (UE4.14):
https://www.dropbox.com/s/1ee9630r6fqbese/Common.usf?dl=0

common.usf (UE4.13.2):
https://www.dropbox.com/s/bagvoru81yc3aij/Common.usf?dl=0

Example Volume Texture of Smoke Ball:
https://www.dropbox.com/s/9h98z1mlhp1yw55/T_Volume_Wisp_01.tga?dl=0

RayMarching Code(레이마칭 코드):

float numFrames = XYFrames * XYFrames;
float accumdist = 0;
float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) );
float StepSize = 1 / MaxSteps;

for (int i = 0; i < MaxSteps; i++)
{
    float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;
    accumdist += cursample * StepSize;
    CurPos += -localcamvec * StepSize;
}

return accumdist;

이 간단한 코드는 텍스처 공간에서 0-1의 거리에 걸쳐 지정된 볼륨 텍스처 속을 통과하게 하여 레이를 진행시키고, 레이가 지나쳐간 미립자들의 선형 밀도를 반환합니다. 완전한 코드는 아니며 중요 세부 사항들이 누락되어 있습니다. 일부는 나중에 코드에 추가될 것이며, 일부 세부 정보는 머티리얼 노드의 형태로 제공될 것입니다.

이를 통해 사용하려는 스텝의 수와 프레임 레이아웃을 제어할 수 있습니다.

DensityTraceMat

이 단순화된 예에서 노드 BoundingBoxBased_0-1_UVW가 사용된 이유는 로컬 0-1 시작 위치를 쉽게 얻을 수 있기 때문입니다. 박스 모양 혹은 구형의 메쉬에 잘 작동되지만, 이번 포스트의 마지막 부분에서도 이를 사용하게 되지는 않을 것입니다. 그리고 그 이유는 잠시 후에 말씀드리겠습니다.


StaticMesh'/Engine/EditorMeshes/EditorCube.EditorCube' 에 64 스텝으로 이를 넣으면
다음과 같은 모습이 나타납니다:
Wisp

무작위적인(random) 볼류메트릭(volumetric) 연기구름입니다. 64스텝이 사용되면 결과는 매우 부드럽게 보입니다. 32개의 스텝을 사용하는 경우에는, 이상한 절단 아티팩트들이 나타납니다:


Wisp_artifacts
(Ray March starting at box intersection 32 steps 박스와의 교차점에서 시작되는 레이마칭 32스텝)

이 아티팩트들은 머티리얼을 렌더링하는 데 사용된 박스 지오메트리를 드러내고 있습니다. 정확히 박스와의 교차점 표면에서 시작되는 볼륨 텍스처 추적(trace)으로 인해 발생하는 일종의 물결 패턴입니다. 이 경우 샘플링 패턴이 박스 모양으로 지속되며 그러한 모습의 패턴을 만들게 됩니다. 시작 위치를 시점을 기준으로 정렬된 평면(plane)에 스냅하면 아티팩트가 줄어들 것입니다.


ViewAlignedPlanes
(Start Positions aligned with box박스에 맞추어져 정렬된 시작 위치들, start Positions snapped to view aligned planes 시점에 맞는 평면에 정렬된 시작 위치들)

이것은 픽셀 셰이더만을 사용한 기하학적 슬라이싱(잘게 썰기) 접근 방식을 모방하는 예입니다. 여전히 절단 아티팩트가 나타나고 있지만 훨씬 덜 눈에 띄며 상자 지오메트리를 드러내지 않고 있는데, 이것이 중요합니다. 시간 지터(temporal jitter)를 도입하면 낮은 스텝 카운트로도 추가적인 샘플링이 이루어질 수 있습니다. 이 부분은 추후에 다루도록 하겠습니다.

다음은 샘플 정렬을 위한 추가 코드입니다.

// Plane Alignment (평면 정렬)
// get object scale factor (오브젝트 스케일 팩터 획득)
//NOTE: This assumes the volume will only be UNIFORMLY scaled. Non uniform scale would require tons of little changes. (참고: 이는 볼륨이 균일하게 크기 조정된다고 가정한 것입니다. 균일하지 않은 스케일은 수많은 작은 변화를 야기하게 될 것입니다.)
float scale = length( TransformLocalVectorToWorld(Parameters, float3(1.00000000,0.00000000,0.00000000)).xyz);
float worldstepsize = scale * Primitive.LocalObjectBoundsMax.x*2 / MaxSteps;
float camdist = length( ResolvedView.WorldCameraOrigin - GetObjectWorldPosition(Parameters) );
float planeoffset = GetScreenPosition(Parameters).w / worldstepsize;
float actoroffset = camdist / worldstepsize;
planeoffset = frac( planeoffset - actoroffset);

float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) );

float3 offsetvec = localcamvec * StepSize * planeoffset;

return float4(offsetvec, planeoffset * worldstepsize);

깊이와 액터 위치가 모두 고려된다는 점에 주의해주세요. 이렇게 하면 액터와 슬라이스가 서로 안정화되므로 카메라가 가까워지거나 혹은 멀어지더라도 움직임이 없게 됩니다. 저는 지금은 이것을 또 다른 커스텀 노드에 넣었습니다. 이렇게 하면 구와 같은 프리미티브가 더 쉽게 추가될 수 있도록, 코드의 설정 파트를 핵심적인 레이마칭 코드 파트로부터 분리하는 데 도움을 줄 수 있을 것입니다. 값이 직접 그리고 한번만 사용되므로 이는 중첩된(nested) 사용자 지정 노드가 아닙니다. 그러므로 다른 사용자 지정 노드가 호출하는 일은 없습니다.


다음 작업은 스텝 카운트를 보다 주의 깊게 제어하는 것입니다. 지금까지 코드는 레이를0-1 공간 내에 위치시키기 위해 saturate(노드)시키고 있었음을 알아차리셨는지요. 이것은 트레이서(tracer)가 상자의 가장자리에 닿을 때마다 볼륨을 확인하는 데 계속해서 시간을 낭비한다는 것을 의미합니다. 또한 추적 거리가 1로 제한되어 있고, 볼륨의 모퉁이와 모퉁이 사이의 거리는 1.732이므로, 모퉁이 간의 거리를 추적하지 못합니다. 지금까지 제시된 예에서는 볼륨의 모양이 둥글었기 때문에 우연히 문제가 되지 않았을 뿐입니다. 이 문제를 해결하는 한 가지 방법은 루프(loop)가 진행되는 동안 레이가 볼륨을 빠져나가는지를 검사(check)하는 것이지만, 그러한 해결책은 루프의 오버헤드를 증가시키기 때문에 이상적이지 않습니다. 가능한 한 간단해야 하기 때문입니다. 더 나은 해결책은 딱 맞는 스텝의 수를 미리 계산하는 것입니다.


박스나 구와 같은 단순한 프리미티브를 사용하면 어렵지 않은 수학으로도 두께를 알아낼 수 있습니다. 더 적은 스크린 픽셀을 커버하기 때문에 구(sphere)가 성능은 더 좋은 모양일 수 있지만, 박스는 볼륨 텍스처 전체를 표시할 수 있으며, 볼륨을 왜곡하는 경우에는 구보다 나은 융통성을 보입니다. 지금은 박스를 사용하겠습니다. 박스에 요구되는 스텝을 미리 계산(precalculate)하는 방법이 있습니다. 월드 > 로컬 transfrom은 메쉬를 움직일 수 있게 해줍니다. 이 경우, 위의 평면(plane) 배열(alignment)을 계산하는 몇 가지 방법이 변경되기 때문에, 위의 코드를 아래의 코드로 변환시켰습니다. 이제 함수가 로컬 Ray Entry(진입) position 및 Thickness(두께)를 직접 반환합니다.


//bring vectors into local space to support object transforms(오브젝트 transform을 서포트하기 위해 벡터를 로컬 공간으로 옮김)
float3 localcampos = mul(float4( ResolvedView.WorldCameraOrigin,1.00000000), (Primitive.WorldToLocal)).xyz;
float3 localcamvec = -normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) );

//make camera position 0-1   (카메라 포지션을 0-1로)
localcampos = (localcampos / (Primitive.LocalObjectBoundsMax.x * 2)) + 0.5;

float3 invraydir = 1 / localcamvec;

float3 firstintersections = (0 - localcampos) * invraydir;
float3 secondintersections = (1 - localcampos) * invraydir;
float3 closest = min(firstintersections, secondintersections);
float3 furthest = max(firstintersections, secondintersections);

float t0 = max(closest.x, max(closest.y, closest.z));
float t1 = min(furthest.x, min(furthest.y, furthest.z));

float planeoffset = 1-frac( ( t0 - length(localcampos-0.5) ) * MaxSteps );

t0 += (planeoffset / MaxSteps) * PlaneAlignment;
t0 = max(0, t0);

float boxthickness = max(0, t1 - t0);
float3 entrypos = localcampos + (max(0,t0) * localcamvec);

return float4( entrypos, boxthickness );


CubeSetup

“Ray Entry”라고 이름 붙여진 노드는 레이 마칭 노드의 CurPos에 연결됩니다. Plane alignment 매개변수(파라미터)는 alignment(배열)을 끄고 켤 수 있는 토글을 활성화시킵니다.

이제 여러분이 피벗(pivot)이 박스의 바닥이 아닌 센터에 위치한 Box static mesh를 사용한다고 일부 코드들은 가정하고 있습니다.


Sorting(정렬)

지금까지 우리는 지오메트리의 로컬 위치를 사용하여 외부로부터의 추적(trace)을 쉽게 시작했지만, 그 경우 카메라가 볼륨 안으로 들어가지는 못합니다. 안으로 들어갈 수 있도록 하기 위해, 위에서 이미 해결된 박스 교차점(box intersection)에서 얻어진 Ray Entry Position(레이 진입 위치) 출력(output)을 사용하고, 이후 박스 지오메트리에 놓인 폴리곤의 겉면(face)을 반대로 향하게 하여(flip) 안쪽을 보게 할 수 있습니다. 이 방식이 통하는 이유는 레이가 볼륨 겉면과 교차되는 위치와, 레이가 볼륨을 통과하는 데 걸리는 시간을 우리가 알고 있기 때문입니다.

겉면을 플립시키고 교차점을 사용하면 카메라가 볼륨 안으로 들어갈 수는 있지만 오브젝트가 바르게 정렬(sort)되지는 않습니다. 큐브 안의 모든 오브젝트는 볼륨의 윗부분에 그려지는 것처럼 보이게 될 것입니다. 이 문제를 해결하기 위해서는, 박스 내부의 광선(ray) 거리를 계산할 때 로컬 씬 뎁스(localized scene depth)를 고려하면 됩니다. 설정 함수(setup function)에 몇 개의 새 라인이 추가될 것입니다:


float scale = length( TransformLocalVectorToWorld(Parameters, float3(1.00000000,0.00000000,0.00000000)).xyz);
float localscenedepth = CalcSceneDepth(ScreenAlignedPosition(GetScreenPosition(Parameters)));

float3 camerafwd = mul(float3(0.00000000,0.00000000,1.00000000),ResolvedView.ViewToTranslatedWorld);
localscenedepth /= (Primitive.LocalObjectBoundsMax.x * 2 * scale);
localscenedepth /= abs( dot( camerafwd, Parameters.CameraVector ) );


//this line goes just before the line: t0 = max(0, t0);
 (이 줄은 t0 = max(0, t0)이 적힌 줄 바로 전에 위치됩니다)

t1 = min(t1, localscenedepth);

이제 머티리얼 설정에서, 머티리얼이 씬과 블렌드되는 방식을 제어하기 위해 Disable Depth Test(depth test 비활성화)는 true로 설정되어야 합니다. 다른 반투명 오브젝트와의 정렬(sorting) 작업은 오브젝트별로 수행되며, 우리는 그것에 대해 제어권이 많지 않지만, 적어도 불투명 오브젝트와의 정렬은 해결할 수 있습니다. 머티리얼 설정에서, 반투명도로 인해 발생하는 가장자리 블렌딩 아티팩트(edge blending artifact)를 피하기 위해 블렌드 모드도 AlphaComposite(알파 합성)로 변경합니다. 또한 머티리얼이 Unlit으로 설정되어 있는지도 확인합니다.

DisableDepthTest
(Translucency 반투명, Directional lighting intensity세기,
Separate(별도의) translucency, Responsive(반응형) AA, Allow custom depth Writes(사용자 지정 depth write 허용))

이제 Scene Depth Lookup(찾아보기) 하나를 추가하여 불투명 지오메트리에 대한 정확한 정렬(sorting)을 생성할 수 있습니다. 이로 인해 레이가 씬 깊이(scene depth)를 넘어서면서까지 축적하지는 않도록 할 수 있으며, 레이마처는 자동으로 올바른 불투명도를 반환하게 됩니다. 하지만 하나의 아티팩트가 여전히 남아 있습니다. 레이 마치를 중단시킬 때 사용되는 스텝의 크기가 정수이므로, 불투명 지오메트리가 볼륨과 교차되는 부분에 계단모양의 아티팩트가 나타나게 됩니다:

DisableDepthTest_01
(Trace distance limited by scene depth  scene depth에 의해 제한된 추적 거리)

이러한 절단 아티팩트를 수정하기 위해서는 한 스텝만 더 나아가면 됩니다. 씬(scene) 깊이에 맞는 스텝 수를 추적한 다음, 남은 부분에 맞는 마지막 스텝을 밟습니다. 이렇게 하면 이음새들을 매끄럽게 해주는 깊이에서 최종 샘플을 얻을 수 있습니다. 주 추적 루프(main trace loop)는 최대한 심플하게 유지되어야 하므로, 주 루프가 아닌 추가적인 밀도/그림자 패스로서 이를 수행합니다.


DisableDepthTest_02

불투명 오브젝트와의 블렌드는 오브젝트가 이동하고 보는 방향이 바뀌어도 정확합니다.



(Ray marched volume depth sorted   레이 마칭된 볼륨의 깊이가 정렬됨)

지금까지는 밀도만을 다루는 레이마처였습니다. 쉐이더의 핵심인 레이 마칭 파트는 아마도 가장 간단한 부분일 것입니다. 다양한 프리미티브에 대한 추적(tracing) 행동을 다루는 것과 샘플링과 Sorting 문제는 조금 더 복잡합니다.

Light Sampling(빛 샘플링)


Smokeball


조명을 받고 있는 볼륨을 문제없이 구현하기 위해서는, 빛 전송(light transport) 행동이 모델링되어야 합니다. 빛의 광선이 볼륨을 통과할 때, 볼륨 내부의 미립자에 의해 일정량의 빛은 흡수되고 일정량은 산란됩니다. 흡수는 얼마나 많은 빛 에너지가 볼륨(부피)에 의해 손실되는가를 말하며 산란은 얼마나 많은 빛이 반사되는지를 말합니다. 흡수(A)와 산란(S)의 비는 입자의 diffuse brightness를 결정합니다 [shopf2007].


이번에는, 한 가지 종류의 산란(scattering)에만 관심을 둘 것입니다: 그것은 Out-Scattering입니다. 이는 볼륨에 도달하는 빛이 등방성(isotropic)으로 또는 분산적으로(diffuse) 반사되어 나오는 것을 뜻합니다. In-Scattering은 볼륨 내에서 빛이 반사되는 것을 의미하며, 일반적으로 실시간으로 수행하기에는 너무 비싸지만, Out-scattering의 결과를 blur시키면 비슷한 모습을 얻어낼 수 있습니다. 주어진 지점에서의 Out-scattering을 알기 위해서는, 빛이 광원에서 그 지점에 도달했을 때 흡수로 인해 얼마나 많은 빛 에너지가 손실되었는지, 그리고 그 후 볼륨에서 다시 빠져나와 눈을 향해 가며 얼마나 많은 에너지가 손실될지를 알아야 합니다.

이러한 값을 계산하는 여러 기법들이 있지만, 이 포스트는 각 밀도 샘플로부터 빛을 향해 중첩된(nested) 레이마치를 수행하는 방법을 다룰 것입니다. 이 방법은 쉐이더의 비용이 DensityStep * ShadowStep 또는 N*M이기 때문에 비용이 많이 듭니다. 또한 구현하기 가장 쉬우며 유연합니다.

Tracing_Shadows
(Light Dir방향, Unshadowed point그림자가 없는 지점, Shadowed point 그림자가 있는 지점, Shadow density accumulation그림자 밀도 누적)

위의 예는 단일 카메라 레이로부터 시작되어 각 밀도 샘플에서 추적되는 중첩(nested) 그림자 샘플을 보여줍니다. 볼륨 매체 내부의 밀도 샘플만 섀도우 샘플을 수행해야 하며, 레이가 볼륨 경계면에 도달하거나, 그림자 밀도가 최대치에 가까운 흡수가 발생한 임계값(스레솔드,threshold)을 초과할 경우에는 섀도 루프가 일찍 종료될 수 있습니다. 이러한 것들이 N * M이 급격해지는 상황을 다소 줄일 수 있습니다.


각 샘플에서 밀도가 얻어지고, 샘플이 다시 산란시킬 수 있는 빛의 양을 결정하는 데 사용됩니다. 그것은 또한 다음 반복 시 얼마나 투과율이 감소하는지에 영향을 줍니다. 이후  셰이더는 빛을 향해 광선을 발사하고, 잠재적 빛 에너지가 얼마나 그 지점까지 도달하였는지 봅니다. 그러므로, 포인트(지점)에서 카메라로 전송되는 눈에 보이는 빛은, 볼륨을 통과하는 총 광자 경로 길이와 포인트 자체의 산란 계수(scattering coefficient)에 의해 제어됩니다. 이 프로세스 또한 1988년 Drebin[1]의 공식으로 설명될 수 있습니다:

Cout(v) = Cin(v) * (1 - Opacity(x)) + Color(x) * Opacity(x)


그러나 위의 공식은 카메라로의 단일 빛 경로만을 설명합니다. Out-scattering으로부터 빛을 전파시키고 볼륨의 불투명도를 계산하려면, 각 샘플 위치에서 빛을 향해 반복되는 레이 샘플을 재생성해야 합니다. 밖으로 나오는 빛 계산을 설명하는 몇 가지 기본 함수들을 정의해 보겠습니다.

선형 밀도는 불투명도 * 밀도 파라미터(매개변수)로서 레이를 따라 각 점 x에서 정의됩니다. 이 파라미터는 사용자가 밀도를 조정할 수 있도록 하지만, 여기서부터는 단순성을 위해 식에서 삭제될 것입니다. 이 파라미터는 또한 볼륨 불투명도에 미리 곱해질 수 있기 때문입니다.

선형 밀도는 다음과 같이 점 x에서 점 x'까지 레이를 따라 누적됩니다:
LinearDensity_eq2

그러므로, 점 x에서 x'까지의 광선(레이) 길이에 해당되는 투과율은 다음과 같이 정의됩니다:
transmittance_eq

이것이 위에서 시작된 밀도 전용 레이마치의 밀도를 계산하는 방법입니다. 빛을 추가하기 위해서는 이제 광선을 따라 있는 각 지점에서의 빛의 산란과 흡수를 고려해야 합니다. 이는 이 항(term)들을 많이 중첩(nesting)하게 됨을 의미합니다. 볼륨 내 x 지점까지 도달하는 Out-scattering의 양은(빛은 w방향으로부터) 다음과 같습니다:

여기서 w는 빛의 방향이고 l는 음의 빛의 방향을 향하는 볼륨 외부의 지점입니다. -LinearDensity(x,l)는 x 지점으로부터 빛을 향하여 볼륨의 경계면에 도달할 때까지의 선형 밀도 누적을 나타냅니다. 이는 빛을 흡수할 미립자의 양을 의미합니다. 이 값은 여전히 해당 지점에서 보이는 빛 양의 값일 뿐이며, 샘플의 불투명도에 기초하여 흡수된 빛의 비율은 아직 설명되지 않습니다. 이를 위해 OutScattering 항에 Opacity(x)를 곱합니다. 또한 빛이 볼륨 밖으로 다시 나갈 때 발생하는 전송 손실도 고려되지 않습니다. 이러한 손실을 설명하기 위해 카메라에서 x 지점까지의 투과율이 결정되어야 합니다.

단순히 단일 점에 대해 설명하는 것이 아닌,  x 지점에서 x' 지점까지 광선 w를 따라 얼마나 많은 out-scattering을 볼 수 있는지를 설명하는 수정된 함수인 TotalOutScattering(x', w)을 만들 수 있습니다:

totaloutscattering

OS와 T는 위의 OutScattering 및 Transmission(전송) 의 약자입니다. OS에 추가하는 것을 잊었지만 Opacity(s)가 곱해져야 합니다. 나중에 다시 추가하도록 하겠습니다. 이 함수는 볼륨을 통과하는 뷰(view) 레이를 따라 나있는 모든 지점(point)들의 총 산란(total scattering)값을 반환합니다. 그것은 사실 너무 지저분해서 확장형으로 작성하기에는 어려운 몇 개의 중첩(nested) 적분입니다. 그래서 우리는 코드 자체를 다루어야 합니다. OutScattering과 같은 항은 처음부터 빛의 색상과 확산(diffuse) 색상으로 곱해질 것임을 암시합니다.
 
보통 이 등식은 다른 논문에서는 Radiance(L)로 표현되지만, 저는 그것을 제외시켰습니다. 이유는 radiance는 볼륨에 전달된 백그라운드 컬러를 고려해야 하는데 그것은 SceneColor * FinalOpacity로서, 여기서는 제가 임의적으로 결정한 이유로 그것을 포함시키지 않기로 한 것입니다:

1) 우리는 그러한 방식으로 백그라운드 컬러를 블렌드시키지는 않을 것입니다. 대신  AlphaComposite 블렌드 모드를 사용하여 opacity에 연결시키겠습니다.


2) 우리는 백그라운드 컬러를 블러시키거나 산란(scattering)시키지는 않을 것입니다. 그것이 제가 그것들을 언급하지 않으려 하지 않는 이유입니다. 전체 수학에 관한 디테일을 참조하시려면 Shopf [2]를 보십시오. 제가 사용한 등식들 중 상당 수는 그곳으로부터 가져온 것입니다. 다만 저는 수학적인 공식 대신 아티스트들에게 친숙한 용어를 사용하려고 노력하였습니다. 그리고 관계를 좀 더 단순화된 방식으로 설명하려 하였습니다.

음영이 있는 볼륨 코드의 예

float numFrames = XYFrames * XYFrames;
float curdensity = 0;
float transmittance = 1;
float3 localcamvec = normalize( mul(Parameters.CameraVector, Primitive.WorldToLocal) ) * StepSize;

float shadowstepsize = 1 / ShadowSteps;
LightVector *= shadowstepsize;
ShadowDensity *= shadowstepsize;

Density *= StepSize;
float3 lightenergy = 0;

for (int i = 0; i < MaxSteps; i++)
{
    float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, numFrames).r;

    //Sample Light Absorption and Scattering
    if( cursample > 0.001)
    {
        float3 lpos = CurPos;
        float shadowdist = 0;

        for (int s = 0; s < ShadowSteps; s++)
        {
            lpos += LightVector;
            float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
            shadowdist += lsample;
        }

        curdensity = saturate(cursample * Density);
        float shadowterm = exp(-shadowdist * ShadowDensity);
        float3 absorbedlight = shadowterm * curdensity;
        lightenergy += absorbedlight * transmittance;
        transmittance *= 1-curdensity;
    }
    CurPos -= localcamvec;
}

return float4( lightenergy, transmittance);

기본적 음영을 추가하는 것만으로도 밀도만을 추적하던 것에 비하면 훨씬 복잡해졌습니다.


이번 버전에서는 카메라벡터와 라이트벡터가 각각의 스텝 사이즈로 처음부터 곱해집니다. 루프의 바깥에서요. 이유는 섀도 트레이싱(추적)은 섀이더를 훨씬 비싸게 만들며 그러한 이유로 가급적 루프의 바깥에서 많은 연산을 수행해야 하기 때문입니다(특히 볼륨 내의 루프로부터 벗어나야 합니다).


위의 쉐이더 코드는 지금의 형태로서는 여전히 아주 느립니다. 하나의 최적화를 추가해야 합니다: 쉐이더는 복셀이 불투명도 > 0.001일 경우에만 그 복셀을 측정합니다. 볼륨 텍스처에 빈 공간이 많은 경우에는 이렇게 하면 많은 시간을 아낄 수 있습니다. 하지만 빈 공간이 없는 경우에는 도움이 되지 않습니다. 이 쉐이더가 실용적일 수 있으려면 추가적인 최적화가 필요합니다.


위 버전의 가장 큰 문제는 밀도 샘플을 얻을 때마다 섀도 스텝도 밟는다는 점입니다. 예를 들어 64개의 밀도 스텝과 64개의 섀도 스텝을 밟는 경우, 4096개의 샘플이 얻어지게 되는 것입니다. 가상의 볼륨 함수는 2번의 lookup(찾아보기)를 수행하기 때문에, 결국 픽셀 당 8192번의 텍스처 찾아보기가 실시된다는 결론이 나오게 됩니다! 이것은 좋지 못하며 최적화가 필요합니다. 레이가 볼륨으로부터 벗어나거나 완전한 흡수(absorption)가 도달되면, 일찍 quit시킬 필요가 있습니다.

첫 번째 파트는 레이가 각각의 섀도 반복 작업을 수행할 때 볼륨으로부터 빠져 나왔는지를 검사(check)하는 것입니다. 다음과 같은 코드가 될 것입니다:


if(lpos.x > 1 || lpos.x < 0 || lpos.y > 1 || lpos.y < 0 || lpos.z > 1 || lpos.z < 0) break;


이러한 검사(check)는 유효하지만, 섀도 루프는 그래도 너무 많이 진행되므로 속도가 개선되지는 않습니다. 각각의 섀도 루프가 실행되기 이전에 섀도 스텝의 수를 미리 계산하는 방법을 시도해 보았습니다. 이는 박스 모양에 대한 밀도 반복 작업(density iteration)의 수를 미리 계산했던 것과 아주 유사한 과정이었습니다. 놀랍게도 이것이 가장 느린 것으로 드러났습니다. 제가 지금까지 알아낸 것 중 섀도 루프를 가장 일찍 종료시키는 방법은 box test 계산입니다:


float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
if(exitshadowbox >= 1) break;




[이 게시물은 금별님에 의해 2018-11-17 02:14:41 이펙트팁게시판에서 이동 됨]

Comments

단무지 2018.10.02 22:42
와~ 정말 제수준을 훨 상외 하는 정보라 정말 어렵지만 이런 좋은 정보 올려주셔서 정말 감사합니다~!
한번 열심히 분석해 보렴니다~


 

Banner
 
Facebook Twitter GooglePlus KakaoStory NaverBand