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



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



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

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




다음으로 추가할 사항은 흡수 스레솔드(threshold)에 기초한 이른 종료입니다. 보통 이것이 의미하는 것은, 투과율이 예를 들면 0.001보다 아래로 내려갈 경우 섀도 루프를 종료시킴을 뜻합니다. 스레솔드가 높으면, 더 많은 아티팩트가 나타납니다. 그러므로 비주얼적으로 문제가 없는 선까지만 스레솔드를 높여야 합니다.


빛 투과율에 각각의 지점이 지닌 불투명도의 역을 곱함으로써만 섀도 마칭 루프를 쓴다면(write), 그 경우 우리는 각각의 반복 작업에서 은연 중 투과율을 알게 될 것이고, 스레솔드 검사(check)는 다음과 같이 단순한 check가 될 것입니다: 


if( transmittance < threshold) break;

하지만 사실 우리는 섀도 반복 작업을 통해 투과율을 계산하는 것이 아닙니다. 처음에 밀도만을 얻는 예에서와 마찬가지로 선형 밀도만을 축적하고 있는 것입니다. 이는 섀도 루프를 가능한 한 싸게 만들려는 노력입니다. 각각의 섀도 축적에 하나만 더해주는(add) 것이 두 개의 곱을 하고 1-x를 하는 것보다 훨씬 싸기(부담이 덜 되기) 때문입니다. 이것은 섀도 스레솔드를 정할 때 전송 값(transmission value)보다는 거리의 관점에서 계산을 해야 함을 뜻합니다.


이를 수행하기 위해 우리는 최종 투과율 항을 invert시킵니다. e ^ (-t * d)입니다. 우리가 확인하고 싶은 것은 t의 값이 몇일 때 투과율이 스레솔드보다 낮은가입니다. 다행히 log(x)가 이를 수행해줍니다. log의 밑의 기본값은 e입니다. 이는 “e의 몇 승이 x인가”에 대한 답을 반환합니다. 그러므로 t가 얼마일 때 투과율이 0.001보다 작을지를 알기 위해서는 우리는 다음을 계산하면 됩니다:


DistanceThreshold = -log(0.001) / d;


유저가 지정한 밀도 값인 d가 1이라고 가정하면, 위의 식에 의해 6.907755의 선형 누적 값이 있어야 0.001의 투과율에 다다를 수 있다는 결론이 나옵니다. 섀이더 코드에 위의 식을 다음 줄과 함께 추가해야 합니다:


float shadowthresh = -log(ShadowThreshold) / ShadowDensity;

ShadowThreshold는 유저가 지정한 투과율 스레솔드이며, ShadowDensity는 유저가 지정한 섀도 밀도 승수(multiplier)입니다. 이 줄은 ShadowDensity를 섀도 스텝 사이즈와 곱하는 줄 다음에 들어가야 하며, 루프의 위에 있어야 합니다.

업데이트된 섀도 코드:

섀도 exit 스레솔드와 투과율 스레솔드를 더하고, 루프(루프도 동일한 섀도 스텝을 수행합니다)의 바깥 부분에서 최종 부분적 스텝 측정이 이루어지는 코드는 다음과 같습니다:


float numFrames = XYFrames * XYFrames;
float accumdist = 0;
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;
float shadowthresh = -log(ShadowThreshold) / ShadowDensity;

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;

            float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
            float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
            shadowdist += lsample;

            if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
        }

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

CurPos += localcamvec * (1 - FinalStep);
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;

        float3 shadowboxtest = floor( 0.5 + ( abs( 0.5 - lpos ) ) );
        float exitshadowbox = shadowboxtest .x + shadowboxtest .y + shadowboxtest .z;
        shadowdist += lsample;
        if(shadowdist > shadowthresh || exitshadowbox >= 1) break;
    }
    curdensity = saturate(cursample) * Density;
    float shadowterm = exp(-shadowdist * ShadowDensity);
    float3 absorbedlight = shadowterm * curdensity;
    lightenergy += absorbedlight * transmittance;
    transmittance *= 1-curdensity;
}

return float4( lightenergy, transmittance);

이제 우리는 하나의 directional light가 있는 경우, 스스로 음영을 드리울 수 있는 반투명 레이 볼륨 레이 마처를 갖게 되었습니다. 조명이 더해지면 위의 섀도 스텝은 추가적으로 반복되어야 합니다. 코드는 각각의 섀도 항에 추가적으로 inverse squared falloff를 계산함으로써 directional light에 point light가 추가되는 경우를 지원할 수 있습니다. 하지만 각각의 밀도 샘플에서 CurPos(current position)로부터 빛으로의 벡터 또한 계산되어야 합니다.


Ambient Light(앰비언트 라이트)

지금까지 우리는 하나의 빛에 의한 Out-scattering만을 다루어왔습니다. 하지만 이 방식의 단점은, 빛이 완전히 가려지는 경우 그늘 속에서 볼륨은 납작한 모습이 된다는 점입니다. 앰비언트 라이트 항이 추가되어 이 문제를 다루게 됩니다. 앰비언트 라이트를 다루는 방법은 많습니다. 하나는 볼륨 텍스처 내에서 앰비언스를 미리 계산하는 것입니다. Deep shadow map처럼요. 이러한 접근법의 단점은 볼륨을 회전시키고 인스턴스(instance)할 수 없다는 점입니다. 앰비언트 라이트가 고정되어 있기 때문입니다. 실시간인 경우의 접근법은 각각의 복셀 위에서 몇몇 레이를 캐스트(cast)하여 위로부터의 섀도잉(overhead shadowing)을 추정하는 것입니다. 오프셋 샘플이 하나 추가되면 이 방식은 수행될 수 있지만, 각각의 평균화된 샘플이 추가되는 경우에 결과가 더 좋게 나옵니다.

미리 구운(prebaked) 것보다는 다이나믹 앰비언트를 선호하게 되는 또 다른 이유로는 만약 여러분이 여러 개의 볼륨 텍스처를 절차적으로(procedural) 쌓아 올리려(stack) 하는 경우를 들 수 있습니다. 이러한 경우의 예가 Horizon Zero Dawn cloud paper [3]에 설명되어 있습니다. 이 논문에서는, 하나의 볼륨 텍스처가 area 전체가 지닌 고유 디테일들을 담은 거시적(macro) 모습을 표현합니다. 그리고 두 번째로 타일되는 볼륨 텍스처가 사용되어 기본 볼륨의 밀도를 조절합니다. 이러한 접근 방식이 강력한 이유는 현재의 렌더링 기법이 해상도에 의해 제한을 받기 때문입니다. 블렌드 조절(blend modulation)을 적용하는 것은 더 많은 디테일을 생성하는 좋은 방법이지만, 이것이 의미하게 되는 것은 미리 계산된 빛이 볼륨 텍스처의 조합으로 인해 만들어지는 새로운 디테일들과 매치되지 않는다는 점입니다.
 
ambientLight

위로부터의(overhead) 앰비언트 오클루젼을 추정하기 위해 세 개의 추가적인 오프셋 샘플을 적용하는 방법입니다. 메인 루프에서 투과율이 곱해지고 난 이후에 들어가면 됩니다:

 

 //Sky Lighting
shadowdist = 0;

lpos = CurPos + float3(0,0,0.05);
float lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.1);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;
lpos = CurPos + float3(0,0,0.2);
lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(lpos), XYFrames, numFrames).r;
shadowdist += lsample;

//shadowterm = exp(-shadowdist * AmbientDensity);
//absorbedlight = exp(-shadowdist * AmbientDensity) * curdensity;
lightenergy += exp(-shadowdist * AmbientDensity) * curdensity * SkyColor * transmittance;

// 표시된 부분 두 개가 있습니다만, 그것들은 사용되는 임시적으로 사용되는 것들의 수를 줄이려는 시도였을 뿐입니다.

코드 전체에 동일한 일이 행해질 수 있습니다.

Light Extinction Color(감광減光 컬러)


우리는 밀도 샘플 하나당 한번, 섀도 항(shadow term)에 LightColor를 적용하고 있습니다. 이렇게 하면 scattering이 depth에 따라 컬러를 바꾸는 것을 허용하지 않게 됩니다. 실제의 구름에서 발생하는 scattering은 미산란으로 빛의 파장을 균일하게 산란시킵니다. 그러므로 구름의 경우 하나의 색이 scatter되는 것이 문제가 되지는 않습니다. 색이 있는 감광은 액체 내에서의 감광 스펙트럼, 해질녘의 IBL(image based lighting) 반응 또는 아티스틱(artistic) 이펙트를 ShadowDensity 매개변수를 V3(3벡터)로 교체하는 것만으로도 모방할 수 있습니다. Shadow Density(밀도)를 여러분이 나타내기를 원하는 색으로 나누면 됩니다:  

extinctioncolor

전체 머티리얼은 다음과 같습니다:
Raymarch_Material

Light color에 phase function(위상 함수)가 추가되었습니다(이는 engine\content에서는 찾을 수 있지만 function library에서는 표시되지 않습니다). 레이마처의 출력(output) 파트에 추가되지 않은 이유는, 위상 함수가 다른 것들로부터 분리되어 directional light에만 적용되고 앰비언트 라이트에는 영향을 주지 않게 하기 위함입니다. 

추가적인 섀도 옵션들

다양한 섀도잉 방식을 지원할 수 있습니다. 이전 포스트에서 다룬 오브젝트별 뎁쓰 기반 커스텀 섀도 맵을 예로 들 수 있는데요, 그러한 방식을 여기에 적용할 수는 있지만 한계가 있습니다. 볼류메트릭의 경우에는 비싼 커스텀 블러링이 수행되지 않는 한 빳빳한 음영이 나타나게 되기 때문입니다(우리는 이미 매우 비싼 중첩된 루프 안에 들어와 있음을 잊지 말아야 합니다). 


지금까지 제가 시도해 본 것은 Distance field shadow입니다. 이 방식이 볼류메트릭에 적합한 이유는 추가적인 비용을 들이지 않고도 부드러운 음영을 얻을 수 있기 때문입니다. 단점은 볼류메트릭적인 목적으로 Global Distance field를 여러 번 찾아보는(look up) 경우 매우 비싸지게 되며, distance field의 해상도도 좋지는 않다는 것입니다. 980 + 레벨의 gpu가 있는 경우에만 이를 시도해보세요.

Distance field shadow를 추가하려면 월드 스페이스 라이트 벡터를 가급적 루프의 바깥에서 패스(pass)시키거나, 다시 계산해야 합니다:


float3 LightVectorWS = normalize( mul( LightVector, Primitive.LocalToWorld));

이후 메인 루프의 내부에서, 섀도 스텝의 바로 다음에:

float3 dfpos = 2 * (CurPos - 0.5) * Primitive.LocalObjectBoundsMax.x;
dfpos = TransformLocalPositionToWorld(Parameters, dfpos).xyz;
float dftracedist = 1;
float dfshadow = 1;
float curdist = 0;
float DistanceAlongCone = 0;
for (int d = 0; d < DFSteps; d++)
{
    DistanceAlongCone += curdist;
    curdist = GetDistanceToNearestSurfaceGlobal(dfpos.xyz);

    float SphereSize = DistanceAlongCone * LightTangent;

    dfshadow = min( saturate(curdist / SphereSize) , dfshadow);

    dfpos.xyz += LightVectorWS * dftracedist * curdist;
    dftracedist *= 1.0001;
}


dfshadow 항은 흡수된 빛(absorbed light)에 곱해집니다. 

 

Temporal Jitter(시간 지터)

종종 높은 스텝 카운트가 사용되더라도 절단 아티팩트가 나타나고는 합니다. 다른 경우에는 볼륨 텍스처 자체의 해상도가 아티팩트를 만들기도 합니다. 낮은 스텝 카운트가 사용되는 경우, 위에서 설명드린 것처럼 plane snapping(스내핑)을 적용하면 정지된 이미지는 개선될 수 있습니다. 하지만 카메라의 움직임은 잘게 자른 슬라이스들이 회전함에 따라 절단 아티팩트를 드러낼 것입니다. 시간 지터는 기본적으로 매 프레임마다 시작 위치를 무작위적으로 이동시킵니다. 그리하여 부드러운 결과가 나오게 합니다. 흔들리는 표면보다 앞쪽에 움직이는 오브젝트가 있지 않은 한 이 방식은 큰 문제가 없습니다.

과거에 저는 DitherTemporalAA 머티리얼 함수를 사용하여 이러한 효과를 얻으려 한 적이 있습니다. 하지만 지금은 더 가볍고 나은 방법이 있습니다. Marc Olano의 향상된 psuedorandom(의사난수) 함수로서 4.12에서 UE4에 추가된 것입니다. 세 줄로 요약될 수 있습니다(로컬 카메라 벡터가 스텝 사이즈에 미리 곱해졌음에 주의하세요):


int3 randpos = int3(Parameters.SvPosition.xy, View.StateFrameIndexMod8);
float rand =float(Rand3DPCG16(randpos).x) / 0xffff;
CurPos += localcamvec * rand.x * Jitter;




마치며


위에서 저는 4.13.2를 사용할 것을 추천드렸습니다. 이유는 4.14에서는 머티리얼 컴파일러에서 핀들 사이에 지시를 공유할 수 없게 되었기 때문입니다. Opacity와 Emissive color를 연결하면 레이마치 함수 전체가 두 번 수행되게 됩니다. 이 경우 4.14에서 사용할 수 있는 방법은 opacity는 1.0으로 설정하고 opacity로 emissive와 scene color를 lerp하는 것입니다.


(더 적어야 하지만 블로그에 길이 제한이 있습니다. 추후 포스트에서 정보를 업데이트하도록 하겠습니다. 참고한 자료들도 적을 수 없게 되었습니다.)

인용:

[1]: Drebin, R. A., Carpenter, L., and Hanrahan, P. Volume rendering.
In SIGGRAPH ’88: Proceedings of the 15th annual conference on Computer
graphics and interactive techniques (1988), pp. 65–74.



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

Comments



 

Banner
 
Facebook Twitter GooglePlus KakaoStory NaverBand