Volumetric Ray Marching
Volume Ray Marching은 구름, 연기, 안개 볼륨 데이터 등 내부 밀도가 공간적으로 분포된 비표면 객체를 실시간 렌더링하기 위한 핵심 기법이다.
위 렌더링 방식은 광선을 작은 스텝으로 쪼개어 진행하면서 매 위치에서 매질의 밀도를 샘플링하고, 광선을 따라 산란된 빛을 누적 적분하는 방식으로 최종 색상과 투명도를 계산한다.

Volumetric Ray Marching 동작 원리
Ray Direction
픽셀의 화면 좌표로부터 월드 공간의 광선 원점과 방향을 계산한다. 전형적으로 카메라 위치를 원점으로, 픽셀의 정규화 좌표를 카메라 좌표계에서 월드 좌표계로 변환하여 방향을 얻는다.
Volume Bound
광선이 볼륨 객체와 교차하는 시작 지점과 끝 지점을 구한다. Bound Box 교차 시작점과 두께를 통해 이후 단계에서 필요한 스텝 수를 미리 계산할 수 있게 한다.
교차가 없으면 해당 픽셀을 discard하여 불필요한 마칭 루프를 패스한다.
bool IntersectAABB(float3 O, float3 InvD, float3 Mn, float3 Mx,
out float tMn, out float tMx)
{
float3 t0 = (Mn - O) * InvD;
float3 t1 = (Mx - O) * InvD;
float3 tNr = min(t0, t1);
float3 tFr = max(t0, t1);
tMn = max(max(tNr.x, tNr.y), max(tNr.z, 0.0));
tMx = min(min(tFr.x, tFr.y), tFr.z);
return tMn <= tMx;
}
float4 main(in PSInput In) : SV_Target
{
SmokeEmitter E = g_Smoke.Emitters[In.InstID];
float R = GetEmitterRadius(E);
float Fade = GetEmitterFade(E);
if (R < 0.01 || Fade < 0.001)
discard;
float3 BoxMin = E.Position - R;
float3 BoxMax = E.Position + R;
float3 CamPos = g_Smoke.CameraPos;
float3 RayDir = normalize(In.WorldPos - CamPos);
float tEntry, tExit;
if (!IntersectAABB(CamPos, 1.0 / RayDir, BoxMin, BoxMax, tEntry, tExit))
discard;
...
}
Phase Function
Phase function은 산란 특성의 방향성을 책임지는 함수이다. 이 함수가 없다면 모든 구름은 균질한 회색 덩어리로 보일 것이다. 구름이 태양 반대편에서는 어둡고 태양 쪽 가장자리는 밝게 빛나는 효과는 위상 함수에서 나온다. 구름, 연기 등 비등방성 매질의 산란 특성을 근사하는 가장 널리 쓰이는 Phase Function은 Henyey-Greenstein이고 수식은 아래와 같다.
cos θ = rayDirection · lightDirection
p(g, cosθ) = (1 / 4π) · (1 − g²) / (1 + g² − 2g·cosθ)^(3/2)
비등방 인자 g(asymmetry factor)는 −1 ≤ g ≤ 1 범위의 매개변수이다.
- 전방 산란( g > 0 ) : 광원이 카메라 뒤에 있을 때 매질이 밝아 보인다.
- 후방 산란( g < 0 ) : 광원과 시점이 같은 쪽에 있을 때 강조한다.
- 등방 산란( g = 0 ) : 모든 방향으로 균등, 결과는 1/4π 상수이다.
float HenyeyGreenstein(float cosTheta, float g)
{
float g2 = g * g;
return (1.0 - g2) / (4.0 * 3.14159265 * pow(max(1.0 + g2 - 2.0 * g * cosTheta, 1e-4), 1.5));
}
Transmittance (Energy Conserving)
매질을 통과하는 빛의 강도가 이동 거리에 비례한다는 Beer-Lambert 법칙을 활용합니다. 해당 법칙의 공식은 다음과 같다.
균일한 매질을 거리 d만큼 통과한 빛의 강도는 지수적으로 감소한다.
T(d) = exp( − σₜ · d )
여기서 σₜ는 소멸 계수(extinction coefficient)로, 흡수 계수 σₐ와 산란 계수 σₛ의 합이다.
σₜ = σₐ + σₛ
밀도가 위치마다 다른 경우에는 단일 d 대신 광로를 따라 적분한다.
T(t₀ → t) = exp( −∫ σₜ(s) · ρ(s) ds )
여기서 ρ(s)는 위치 s에서의 밀도 스칼라이며, 코드에서는 노이즈 함수나 3D 텍스처 샘플링으로 얻는다.
for (int i = 0; i < g_Smoke.MaxSteps; ++i)
{
float t = tMin + (float(i) + jitter) * StepSize;
if (t >= tMax) break;
float3 Pos = CamPos + RayDir * t;
float density = SampleDensity(Pos, E, R, Fade, BoxMin, BoxSize);
if (density > 0.001)
{
float sigma_t = density * g_Smoke.DensityScale;
// Shadow march
...
float stepT = exp(-sigma_t * StepSize);
float3 directLight = g_Smoke.LightColor * lightT * phase;
float3 ambient = g_Smoke.SmokeAlbedo * g_Smoke.AmbientIntensity * lightT;
lightEnergy += transmittance * (directLight + ambient) * (1.0 - stepT);
transmittance *= stepT;
if (transmittance < 0.01)
break;
}
}
Light Density Sampling
Light Density Sampling은 Ray Marching의 매 샘플점에서 광원까지의 경로에 대한 교차점을 별도로 샘플링하여 그 경로의 누적 밀도를 측정하고, 이를 광원 빛의 감쇠로 환산하는 절차다. 이 단계가 없으면 볼륨은 Self Shadow를 갖지 못하고 평면적인 덩어리로 보인다.
// Shadow march
float lightT = 1.0;
if (density > 0.02)
{
float shadowOD = 0.0;
for (int s = 0; s < g_Smoke.ShadowSteps; ++s)
{
float3 lpos = Pos + LightDir * ((float(s) + shadowJitter) * shadowWorldStep);
if (any(lpos < BoxMin) || any(lpos > BoxMax))
break;
shadowOD += SampleDensityLight(lpos, E, R, Fade, BoxMin, BoxSize)
* shadowWorldStep;
}
lightT = exp(-shadowOD * g_Smoke.ShadowDensity);
}
Pseudo-Volume Flipbook Sampling
텍스처는 N×N 그리드에 볼륨 슬라이스를 구워 둔 2D 스프라이트 시트로 제공된다. 임의의 3D UVW에서 Z축을 따라 두 개의 인접 타일을 찾아 XY 평면에서는 bilinear, Z에서는 linear로 결합한다.
float SamplePseudoVolume(float3 uvw)
{
int F = g_Smoke.XYFrames;
float N = float(F * F);
uvw = frac(uvw);
float z = uvw.z * (N - 1.0);
float zFloor = floor(z);
float zFrac = z - zFloor;
float invF = 1.0 / float(F);
float2 tile0 = float2(fmod(zFloor, float(F)), floor(zFloor * invF));
float2 tile1 = float2(fmod(min(zFloor + 1.0, N - 1.0), float(F)),
floor(min(zFloor + 1.0, N - 1.0) * invF));
float s0 = g_tex2DVolumeTex.SampleLevel(g_tex2DVolumeTex_sampler, (tile0 + uvw.xy) * invF, 0);
float s1 = g_tex2DVolumeTex.SampleLevel(g_tex2DVolumeTex_sampler, (tile1 + uvw.xy) * invF, 0);
return lerp(s0, s1, zFrac);
}
3-Axis Position Distortion
UV를 한 방향으로만 밀기보다는, 샘플링 좌표 자체를 3축 독립 변위 벡터로 흔들어 연기가 흩어지는 느낌이 나도록 만든다. 각 축은 서로 다른 평면(yz, xz, xy)을 서로 다른 시간 오프셋으로 읽어 비균일 회전을 유도한다.
float3 ComputeDistortion(float3 localPos, float age, float seed)
{
float t = age * g_Smoke.WindSpeed;
float dx = g_tex2DNoiseTex.SampleLevel(g_tex2DNoiseTex_sampler,
localPos.yz * 0.8 + float2(t * 0.7, 0.0) + seed, 0) * 2.0 - 1.0;
float dy = g_tex2DNoiseTex.SampleLevel(g_tex2DNoiseTex_sampler,
localPos.xz * 0.8 + float2(0.0, t * 0.5) + seed + 3.7, 0) * 2.0 - 1.0;
float dz = g_tex2DNoiseTex.SampleLevel(g_tex2DNoiseTex_sampler,
localPos.xy * 0.8 + float2(t * 0.6, t * 0.3) + seed + 7.1, 0) * 2.0 - 1.0;
return float3(dx, dy, dz);
}
Temporal Blue Noise Jitter
Ray Marching된 장면에 파란색 노이즈 디더링을 활용하여 스텝 수가 적거나 루프의 세분화 정도가 낮아 발생하는 밴딩 또는 레이어링 효과를 제거하는 방법이다. 이 기법은 다른 노이즈보다 패턴이나 덩어리가 적고 사람의 눈에 덜 띄는 파란색 노이즈 패턴을 사용하여 실행될 때마다 난수를 생성하지만 가끔 디더링 패턴이 눈에 띄는 문제가 생길 수 있다. 이를 보완하기 위해 시간적 요소인 Frame Index를 활용하여 이를 완화한 난수를 생성한 후 이 난수를 Ray Marching 루프 시작 부분에 오프셋으로 반영하여 출력의 각 픽셀에 대해 샘플링 시작점을 레이를 따라 이동시킨다.
float TemporalBlueNoise(float2 pixelCoord, int frameIndex)
{
float blueNoise = g_tex2DBlueNoiseTex.SampleLevel(g_tex2DBlueNoiseTex_sampler, pixelCoord, 0).r;
return frac(n + float(frameIndex % 32) * sqrt(0.5));
}
적용 영상
'Graphics' 카테고리의 다른 글
| Deferred Decal Tangent Space Normal Blend (1) | 2026.04.13 |
|---|---|
| Two-Pass HZB Occlusion Culling (0) | 2026.04.10 |
| Animation Compression (0) | 2025.04.22 |
| Animation Retargeting (0) | 2025.04.16 |
| Texture Block Compression (0) | 2025.04.15 |