하늘, 일몰, 행성 렌더링
47 minutes ago
1
브라우저 셰이더가 Rayleigh 산란 , Mie 산란, 오존 흡수를 조합해 파란 하늘과 일몰·일출을 실시간 렌더링함
카메라 광선의 광학 깊이 와 Beer's Law 투과율을 누적하고, 위상 함수로 태양 방향에 따른 산란 분포를 계산함
일몰 효과는 각 샘플에서 태양 방향으로 별도 light-march 를 수행해, 태양빛이 대기를 통과하며 잃는 양을 반영함
평면 하늘 셰이더는 depth buffer와 월드 좌표 복원으로 후처리 효과 가 되어, 장면 오브젝트 사이 대기 안개까지 처리함
행성 규모에서는 logarithmic depth buffer , ray-sphere intersection, LUT 기반 Transmittance·Sky-view·Aerial Perspective로 확장됨
대기 산란 셰이더의 목표와 참고 자료
하늘 렌더링의 기본 모델
단순 그라디언트가 부족한 이유
하늘색은 단순한 파란 배경이 아니라, 빛이 공기와 그 구성 요소와 상호작용한 결과로 다뤄야 함
관측자의 고도, 먼지의 양, 시간대 같은 변수를 고려해야 하며, 계산은 부피(volume) 안에서 이뤄짐
대기 밀도 샘플링
Rayleigh 밀도와 광학 깊이
투과율을 구하려면 광선이 지나며 만나는 대기 밀도를 누적해 광학 깊이(optical depth) 를 계산해야 함
Rayleigh 밀도 함수는 고도 h에서 “공기”가 얼마나 있는지를 나타내며, 고도가 높아질수록 대기가 희박해지는 효과를 반영함
예시 구현은 RAYLEIGH_SCALE_HEIGHT = 8.0km, ATMOSPHERE_HEIGHT = 100.0km, VIEW_DISTANCE = 200.0km, PRIMARY_STEPS = 24를 사용함
rayleighDensity(h)는 exp(-max(h, 0.0) / RAYLEIGH_SCALE_HEIGHT)이고, 루프에서 viewOpticalDepth += dR * stepSize로 누적됨
Beer's Law와 낮 하늘의 파란색
광학 깊이에서 특정 지점의 투과율 T를 계산하며, T=1.0은 빛 손실 없음, T=0.0은 빛이 완전히 사라졌다는 뜻임
투과율은 Beer's Law 로 계산되며, 예시 코드는 vec3 transmittance = exp(-rayleighBeta * viewOpticalDepth)를 사용함
rayleighBeta는 Rayleigh 산란 계수이며, 셰이더에서는 vec3(0.0058, 0.0135, 0.0331)로 저장됨
태양광 방향과 시선 광선 사이의 각도는 3.0 / (16.0 * PI) * (1.0 + mu * mu) 형태의 Rayleigh phase function 으로 모델링됨
Rayleigh 산란 계수 때문에 빨강은 거의 산란되지 않고, 초록은 조금 더 산란되며, 파랑이 가장 많이 산란되어 낮의 하늘이 파랗게 보임
픽셀마다 하나의 광선으로 확장하면 지평선 쪽은 더 많은 대기를 통과해 밝은 흰 안개처럼 보이고, 고도가 높아질수록 더 깊고 어두운 파란색으로 바뀜
Mie 산란과 오존 흡수
Rayleigh만으로 부족한 효과
Rayleigh 산란만으로도 괜찮은 결과를 얻을 수 있지만, 더 현실적인 하늘에는 추가 대기 효과가 필요함
Mie 산란 은 먼지나 에어로졸처럼 더 큰 입자와 빛의 상호작용을 나타내며, 밀도 함수와 방향별 재분배를 나타내는 위상 함수를 가짐
오존 흡수 는 상층 대기를 통과하는 빛의 일부 파장을 산란시키지 않고 경로에서 제거함
오존 흡수는 특히 지평선, 일몰, 일출 전후 박명에서 하늘색을 더 깊게 만들고 색을 이동시킴
Mie와 오존의 누적
Rayleigh, Mie, 오존을 함께 쓰는 구현은 viewODR, viewODM, viewODO로 각각의 광학 깊이를 누적함
각 샘플에서는 dR = rayleighDensity(h), dM = mieDensity(h), dO = ozoneDensity(h)를 계산하고, tau를 BETA_R * viewODR, BETA_M_EXT * viewODM, BETA_OZONE_ABS * viewODO의 합으로 구성함
투과율은 exp(-tau)로 계산되고, sumR, sumM, sumO에 각 밀도와 투과율, stepSize가 누적됨
최종 산란은 SUN_INTENSITY * (phaseR * BETA_R * sumR + phaseM * BETA_M_SCATTER * sumM + BETA_OZONE_SCATTER * sumO) 형태로 계산됨
주요 상수와 효과
MIE_SCALE_HEIGHT는 에어로졸용 RAYLEIGH_SCALE_HEIGHT에 해당하며, 입자가 보통 지평선 가까이에 집중되므로 1.2km로 더 작게 설정됨
MIE_BETA_SCATTER는 입자가 빛을 카메라 쪽으로 얼마나 산란시키는지 제어하며, 대부분 파장 독립적이어서 vec3(0.003)으로 설정됨
MIE_BETA_EXT는 경로에서 얼마나 많은 빛이 제거되는지 나타내는 Mie 소멸 계수이며, 먼 대기를 더 뿌옇게 보이게 만듦
MIE_G는 이방성을 제어하며, 0.0은 균일 산란, 1.0은 더 강한 전방 산란 편향을 뜻함
OZONE_BETA_ABS는 vec3(0.00065, 0.00188, 0.00008) 값을 가지며, 초록과 노랑-주황 계열을 더 많이 흡수해 하늘색을 파랑·빨강·보라 쪽으로 이동시킴
Mie와 오존을 통합하면 더 자연스러운 “sky blue” 색과 태양 주변의 뿌연 빛무리가 생기며, 태양이 지평선 가까이에 있을 때 Mie 산란 효과가 더 뚜렷해짐
빛 경로와 일몰·일출
기존 구현의 한계
sky fragment shader는 다양한 고도에서 자연스러운 색을 렌더링하고 Mie, Rayleigh, 오존 투과율 모델을 반영할 수 있음
그러나 태양을 지평선 가까이 옮겨도 빛 감쇠나 일몰·일출 효과 없이 흰색의 뿌연 빛무리 만 나타남
기존 raymarching 루프가 카메라에서 각 샘플까지의 시선 광선에서만 빛 감쇠를 계산했기 때문임
샘플 지점에 도달하기 전, 태양광이 대기를 통과하며 얼마나 손실되는지도 계산해야 함
light-march 중첩 루프
각 샘플 지점에서 광원 방향으로 별도 중첩 루프를 돌려 해당 경로의 투과율 을 샘플링함
관련 접근은 real-time cloudscapes 와 volumetric lighting 에서도 사용됨
lightMarch(float start, float sunY)는 LIGHTMARCH_STEPS만큼 반복하면서 odR, odM, odO를 누적함
기존 구현의 광학 깊이 viewODR, viewODM, viewODO에 태양 방향 광학 깊이 sunOD를 더함
최종 tau는 BETA_R * (viewODR + sunOD.x), BETA_M_EXT * (viewODM + sunOD.y), BETA_OZONE_ABS * (viewODO + sunOD.z)를 합쳐 구성됨
이 구현으로 일몰, 일출, 천정의 태양, 그 사이 조명 조건의 하늘을 렌더링할 수 있음
sun angle uniform으로 하루 동안의 하늘 파란색 변화를 만들고, Mie 산란은 일몰과 일출에서 빛을 지평선과 자연스럽게 섞어줌
태양이 낮을 때 오존은 하늘에 보랏빛 톤 을 더함
행성 대기로 확장
평면 배경에서 후처리 효과로
앞서 만든 셰이더는 좋은 하늘 배경을 제공하지만, React Three Fiber 장면의 평면 배경에 가까움
다음 단계는 이를 후처리 효과(post-processing effect) 로 바꿔 장면 깊이를 고려하는 부피와 행성 메시에 둘러싼 대기 껍질로 렌더링하는 것임
이를 위해 screenUV 좌표에서 월드 공간 좌표를 재구성하고, 장면의 depth buffer를 raymarching에 반영함
월드 공간 재구성과 3D 광선
대기 산란을 장면에 적용하려면 하늘만 그리는 것이 아니라 카메라와 화면에 렌더링된 오브젝트 사이의 공간을 채워야 함
필요한 데이터는 장면의 depth buffer, 카메라의 projectionMatrixInverse, matrixWorld, position이며, 이 값들을 후처리 효과의 uniform으로 전달함
getWorldPosition(vec2 uv, float depth)는 depth * 2.0 - 1.0으로 clipZ를 만들고, uv * 2.0 - 1.0로 NDC 좌표를 만든 뒤 projectionMatrixInverse와 viewMatrixInverse를 적용함
같은 과정은 On Shaping Light 의 volumetric lighting 후처리 효과에도 사용됨
현재 픽셀의 worldPosition을 얻은 뒤 rayOrigin은 카메라 위치로, rayDir은 normalize(worldPosition - rayOrigin)으로 계산해 화면상의 픽셀별 3D 광선 을 따라 전진함
깊이 버퍼로 raymarch 구간 조정
장면 지오메트리를 고려하려면 고정 stepSize 대신 depth buffer로 현재 광선의 raymarch 구간을 정해야 함
sceneDepth = depthToRayDistance(uv, depth)로 광선상의 장면 깊이를 구함
배경 픽셀은 depth >= 1.0 - 1e-7로 판별하고, “sky pixels”에는 sceneDepth = atmosphereHeight * SKY_MARCH_DISTANCE_MULTIPLIER를 적용함
광선이 아래쪽을 향하면 tGround = observerAltitude / max(-rayDir.y, 1e-4)로 지면 교차를 계산하고 rayEnd = min(rayEnd, tGround)로 제한함
최종 stepSize는 (rayEnd - rayStart) / float(PRIMARY_STEPS)로 계산함
가까운 오브젝트나 지면에 닿는 광선은 작은 stepSize로 더 정확히 샘플링되고, 멀리 가는 광선은 같은 수의 샘플을 더 긴 거리 위에 분포시킴
장면 안 대기 안개
후처리 효과로 구현된 셰이더는 장면의 부피 전체에 대기 산란을 적용하고, 장면 지오메트리를 고려하면서 sky shader를 배경으로 사용할 수 있음
카메라에 가까운 오브젝트는 더 선명하게 보이고, 멀리 있는 오브젝트는 더 많이 흐려짐
Raycaster로 드래그 가능한 천체를 넣은 상호작용 예시는 MaximeHeckel의 트윗 에서 확인 가능함
행성 렌더링
필요한 두 단계
행성 주변에 사실적인 대기를 렌더링하려면 큰 스케일을 처리하기 위한 logarithmic depth buffer 와, 광선이 대기에서 어디서 시작하고 끝나는지 정의하는 구 형태의 대기 껍질이 필요함
logarithmic depth buffer
행성 규모에서는 멀리서 볼 때 대기와 행성 껍질의 깊이 차이를 셰이더가 구분하기 어려워 depth fighting 이 발생할 수 있음
대기 높이는 몇 km에 불과하므로, 장면의 depth buffer 정의와 후처리 효과에서의 읽기 방식을 모두 조정해야 함
React Three Fiber의 Canvas를 감싸는 gl prop에서 logarithmicDepthBuffer: true를 설정함
예시 설정은 <Canvas shadows gl={{ alpha: true, logarithmicDepthBuffer: true }}> 형태임
셰이더에서는 logarithmic depth buffer를 광선상의 거리로 되돌리기 위해 sceneDepth 계산을 다시 정의함
logDepthToViewZ(depth)는 pow(2.0, depth * log2(cameraFar + 1.0)) - 1.0를 사용하고 -d를 반환함
ray-sphere intersection으로 대기 구간 찾기
시선 광선이 대기 구(atmospheric sphere) 에 들어가고 나오는 지점을 찾기 위해 ray-sphere intersection test를 사용함
두 교차점을 얻으면 대기 밖에서 샘플을 낭비하지 않고, 해당 구간으로만 raymarching 루프를 제한할 수 있음
행성은 구형 메시이고 그보다 약간 큰 대기 구가 둘러싼 형태이므로, 같은 교차 테스트를 행성 자체에도 수행함
광선이 대기를 빠져나가기 전에 지면에 닿으면, 지면 교차점을 raymarching 구간의 끝으로 사용함
사용된 raySphereIntersect 구현은 Inigo Quilez의 Ray-Surface intersection functions 를 참고함
장면 오브젝트와 대기 종료 조건
대기는 행성 표면에 닿거나, 지면에 닿기 전에 다른 장면 오브젝트를 만나면 종료되어야 함
행성에 닿는 경우 기본적으로 atmosphereFar = min(atmosphereFar, planetHit.x)로 지면에서 멈춤
다른 메시가 지면 앞에 렌더링되어 있으면 sceneDepth < planetHit.x - 2.0 조건으로 판별하고 atmosphereFar = min(atmosphereFar, sceneDepth)를 적용함
이 로직이 없으면 행성 표면이 오브젝트보다 앞에 나타나는 문제가 생김
React Three Fiber 데모와 남은 글리치
두 조정을 코드에 반영하면 대기 산란을 후처리 효과로 구현하고, 행성 주변의 대기를 렌더링할 수 있음
데모 장면은 React Three Fiber에서 간단한 “Sun - Earth system”을 렌더링하고 커스텀 효과를 적용함
태양 위치를 조정하고 줌아웃하면 지상에서 궤도까지 다양한 각도에서 셰이더가 만드는 하늘색을 볼 수 있음
같은 효과가 4월 초 글 예고용 포스터 이미지에 사용되었으며, 렌더 이미지는 트윗 으로 공유됨
장면의 torus는 해가 진 뒤에도 여전히 “lit-up” 상태로 보일 수 있음
원인은 주 directional light의 shadow-map 또는 shadow-camera 스케일이 작아, 너무 멀리 있는 torus를 덮지 못하는 데 있음
우회책으로 volumetric lighting article 의 shadow-mapping 접근을 재사용할 수 있지만, 실제로 시도되지는 않음
일식 처리
큰 천체가 태양을 가리는 경우 는 lightMarch 이후 sunVisibility 함수를 호출하고, 반환값 [0, 1]을 투과율에 곱하는 방식으로 추가할 수 있음
기본 아이디어는 현재 샘플 지점에서 달 방향 과 태양 방향 의 내적을 비교하는 것임
두 방향이 거의 같아 내적이 1.0에 가까우면 달이 태양을 가리는 상태이고, 직교해 0.0에 가까우면 가림이 없음
단순 내적만으로는 장면 안 객체의 크기와 스케일 을 반영하지 못하므로, 구현은 태양과 달의 각거리와 각각의 각반지름을 비교함
sunVisibility는 달이 태양을 가리지 않는 경우, 달이 카메라 시점에서 태양보다 크거나 비슷한 크기로 보이는 상태에서 가리는 경우, 달이 카메라 시점에서 태양 반지름 안에 들어가는 상태로 가리는 경우를 다룸
데모는 기존 대기 산란 예제 위에 sunVisibility와 달 메시 를 추가해, 달을 태양과 정렬했을 때 빛이 부족한 상황을 Atmospheric Scattering 셰이더가 처리하도록 함
더 정교한 일식과 코로나 시뮬레이션은 Physically Based Real-Time Rendering of Eclipses 논문에서 다루며, 해당 논문 구현은 WebGL로 포팅하지 않음
다른 행성의 대기
사용한 대기 밀도와 산란 모델은 행성과 대기의 반지름, RayleighScaleHeight, RayleighBeta, MieScaleHeight, MieBeta, mieBetaExt, mieG, OzoneHeight, OzoneWidth 같은 몇 가지 상수로 대부분 결정됨
이 값들을 조정하면 화성 대기 나 다른 행성의 대기에 가까운 결과를 만들 수 있음
화성용으로 사용한 값은 근사치임
planetRadius: 3390
atmosphereRadius: 3500, 약 110 km 두께
rayleighScaleHeight: 11.1
rayleighBeta: new THREE.Vector3(0.019, 0.013, 0.0057)
mieScaleHeight: 1.5
mieBeta: 0.04
mieBetaExt: 0.044
mieG: 0.65
ozoneCenterHeight: 0.0
ozoneWidth: 1.0
ozoneBetaAbs: new THREE.Vector3(0.0, 0.0, 0.0)
sunIntensity: 15.0
planetSurfaceColor: '#8B4513'
기존 상수를 이 값들로 바꾸면 더 먼지 많고 주황빛인 대기 가 나오며, 화성의 특징적인 일몰 시 푸른 색조 도 얻을 수 있음
관련 논문으로 Physically Based Rendering of the Martian Atmosphere 가 있음
LUT 기반 대기 산란
접근 방식과 단축한 부분
기존 셰이더는 작은 스케일과 큰 스케일의 대기를 직관적으로 렌더링할 수 있지만, PRIMARY_STEPS가 많은 레이마칭 루프, lightmarching 중첩 루프, 전체 화면 해상도 계산 때문에 실행 비용이 큼
Sebastian Hillaire의 A Scalable and Production Ready Sky and Atmosphere Rendering Technique 는 비용이 큰 산란 계산을 텍스처에 저장하고, 최종 렌더에서 미리 계산된 텍스처를 샘플링·합성하는 Look Up Tables(LUTs) 기반 방식을 제안함
다루는 LUT는 빛이 대기를 통과하면서 살아남는 양을 저장하는 Transmittance LUT , 특정 카메라 위치에서의 하늘 색을 저장하는 Sky-view LUT , 카메라와 보이는 장면 지오메트리 사이의 대기 헤이즈와 산란광을 저장하는 Aerial Perspective LUT 임
전체 논문 구현을 그대로 옮기지는 않았고, LUT는 WebGPU의 compute shader에 적합하지만 시간 부족과 글의 연속성 때문에 WebGL 을 유지함
논문에서 Aerial Perspective LUT는 3D texture 지만, 구현에서는 2D render target을 사용함
이 방식은 카메라가 움직일 때마다 올바른 픽셀 값을 위해 텍스처를 다시 생성해야 하며, 미리 계산해두기 어려움
Multi-Scattering 은 시간 부족으로 생략됨
Transmittance LUT
기존 셰이더에서는 모든 샘플 지점이 lightmarch를 호출해 태양빛이 얼마나 도달하는지 계산했으며, 이 과정이 비쌈
Transmittance LUT 는 이 데이터를 낮은 해상도로 미리 저장해, 이후 LUT들이 빛 데이터를 필요로 할 때 읽어 쓰도록 함
구현은 250 x 64 해상도의 전용 Frame Buffer Object를 정의하고, 커스텀 셰이더 material을 전용 장면 transmittanceLUTScene의 full-screen quad에 적용한 뒤 렌더 결과 텍스처를 downstream LUT의 uniform으로 전달함
각 픽셀에서 vec3(0.0, radius, 0.0)부터 레이마칭하며, radius는 vUv.y 좌표를 따라 planetRadius에서 atmosphereRadius까지 증가함
LUT의 x축 은 빛의 각도, y축 은 고도를 나타내며, 순수한 흰색은 100% 투과율이고 검은색 또는 색이 있는 영역은 지면이나 공기가 가장 두꺼운 부분을 나타냄
이후 LUT들은 “주어진 각도와 고도에서 대기를 통과해 살아남는 빛의 양”을 텍스처 조회만으로 얻을 수 있음
Sky-view LUT
Sky-view LUT는 특정 방향을 지상에서 올려다볼 때 하늘이 어떤 색인지 계산함
getSkyViewRayDir는 vUv.x를 azimuth [-PI, PI]에, vUv.y를 elevation [-PI/2, PI/2]에 매핑해 레이마칭 방향을 정의함
elevation에는 (vUv.y * vUv.y - 0.5) * PI라는 quadratic mapping을 사용하며, 먼 거리에서 Sky View가 너무 많이 깜빡이는 것을 피하기 위한 우회책임
레이가 대기에 들어가지 않으면 검은색을 반환하고, 행성에 닿는 레이는 보이는 대기 구간까지만 레이마칭하며 행성에 닿으면 더 일찍 멈춤
산란 루프는 이전과 같지만, Sky View 방향을 따라 진행하며 태양빛에는 Transmittance LUT를 사용함
Aerial Perspective LUT
Hillaire 논문과 달리 구현 결과는 2D 텍스처 이고, 각 픽셀은 보이는 화면 픽셀 하나에 대응함
장면 depth buffer를 사용해 해당 레이를 따라 얼마나 멀리 행진하고 산란을 누적할지 결정함
기존 산란 코드를 거의 재사용하되, 각 샘플이 Transmittance LUT에서 태양빛 가시성을 가져옴
출력은 RGB에 누적된 대기 산란을 저장하고, 알파에는 합성 때 사용할 packed view transmittance 값을 저장함
구현 흐름은 depthBuffer에서 깊이를 읽고, getWorldPosition(vUv, depth)로 화면 픽셀의 월드 공간 위치를 복원한 뒤, 카메라 위치에서 월드 위치까지의 rayDir을 계산하는 방식임
이어서 logDepthToRayDistance(vUv, depth)로 장면 깊이를 레이 거리로 변환하고, 대기와 행성 교차를 계산한 뒤 보이는 대기 구간만 march함
합성
Sky-view LUT와 Aerial Perspective LUT를 생성한 뒤 마지막 post-processing pass에서 둘을 결합함
핵심 작업은 현재 rayDir을 Sky View UV 좌표 로 변환하는 것임
장면 지오메트리에는 Aerial Perspective LUT를 적용하며, 알파 채널은 view transmittance로, RGB 채널은 산란광으로 사용해 color = color * aerialPerspective.a + aerialPerspective.rgb를 계산함
배경 픽셀에는 Sky View LUT를 샘플링하며, depth >= 1.0 - 1e-7이면 배경으로 보고 color = inputColor.rgb + sampleSkyViewLUT(rayDir, planetCenter)를 적용함
마지막으로 ACESFilm(color)와 pow(color, vec3(1.0 / 2.2))를 적용함
전체 LUT 기반 대기 구현 코드는 Github link 에서 확인할 수 있음
마무리
LUT 기반 대기 산란 결과는 이전 완전 레이마칭 버전과 거의 같아 보일 수 있지만, 내부 과정은 다름
작업을 더 작은 LUT들로 나누고 마지막 효과에서 합성하며, 각 샘플마다 태양 쪽으로 반복 레이마칭해 도달 빛을 계산하지 않음
Transmittance LUT에서 조명 정보를 직접 가져오므로, 비용 큰 중첩 루프를 단순 텍스처 조회로 대체하고 최종 장면에서 무시할 수 없는 성능 향상을 얻음
구현은 Sébastian Hillaire와 다른 분야 구현에 비하면 부족하며, 특히 Sky View에서 banding과 flickering이 있고 단축한 부분 때문에 최적성이 떨어짐
처음부터 WebGPU를 썼어야 했을 가능성이 있음
실제 production-grade 구현으로 Shoda Matsuda(@shotamatsuda )의 three-geospatial 을 추천함
추가로 volumetric clouds를 얹는 작업도 했지만, 결과가 아직 mixed bag이며 글로 보여줄 만큼 만족스럽지는 않아 더 작업이 필요함
Homepage
Tech blog
하늘, 일몰, 행성 렌더링
🔉 볼륨 줄이기
🔊 볼륨 키우기
🔇 음소거
⏭️ 다음 곡