셰이더로 사실적인 지구 만들기

0

다시 Three.js로 돌아와 사실적인 지구를 만들어 보자. 이전의 지구1와 달리, 이번에는 셰이더 재질로 지구를 렌더링한다. 첫째, 밤에는 도시의 불빛이 있으므로 두 개의 서로 다른 텍스처로 낮과 밤을 표현한다. 둘째, 텍스처를 더 사실적으로 만들기 위해 산의 그림자와 바다의 반사 효과를 만든다. 또한 구름과 그 그림자를 표현한다. 셋째, 정교한 식(蝕) 그림자를 지구 표면에 드리운다. 마지막으로 대기와 그 프레넬(Fresnel) 효과를 렌더링한다.

이 글은 태양계 시뮬레이터 시리즈의 일부다:

  1. Three.js 좌표계 기초
  2. Three.js PBR (물리 기반 렌더링) 재질 기초
  3. 사실적인 지구 만들기
  4. 사실적인 태양 만들기
  5. 고리가 있는 행성 만들기
  6. 불규칙한 형태의 위성 만들기
  7. 태양을 빛나게 하기
  8. 은하수 스카이박스 만들기
  9. 타원 궤도 계산하기

낮과 밤

이전 글1에서 지구의 낮과 밤은 밝기만으로 생성되었다. 문명이 없다면 그것으로 충분할 것이다. 그러나 현재의 도시는 밤의 불빛이 매우 밝아 빛 공해를 일으킬 정도다. 도시의 불빛을 표현하기 위해 밤 텍스처 u_nightTexture를 사용한다. Three.js에서는 uniform 변수를 이용해 텍스처 맵을 셰이더로 전달할 수 있다. 관련 글2을 참고하길 바란다.

const material = new THREE.ShaderMaterial({
    uniforms: {
        u_dayTexture: { value: new THREE.TextureLoader().load( PATH_TO_DAY_IMAGE) },
        u_nightTexture: { value: new THREE.TextureLoader().load( PATH_TO_NIGHT_IMAGE) },
    },
})

한편, 텍스처 맵은 다음과 같이 선언할 수 있다.

uniform sampler2D u_dayTexture;
uniform sampler2D u_nightTexture;

아래는 지구의 낮 맵과 밤 맵의 예시다.

그러면 텍스처의 RGB 값은 Three.js가 제공하는 속성인 UV 위치 vUv를 통해 접근할 수 있다.

vec3 dayColor = texture2D( u_dayTexture, vUv ).rgb;
vec3 nightColor = texture2D( u_nightTexture, vUv ).rgb;

두 텍스처를 섞으려면 둘 사이의 가중치를 계산해야 하는데, 이는 태양의 방향에 따라 달라진다. 따라서 지구에 대한 태양의 상대 위치 u_sunRelPosition을 셰이더가 알 수 있도록 해야 한다.

uniform vec3 u_sunRelPosition; // the relative position of light source

void main( void ) {
    vec3 sunDir = normalize(u_sunRelPosition);
    ...
}

그러면 태양 방향과 지구 표면 법선 벡터의 내적은 낮과 밤을 정의하는 햇빛의 입사각을 나타낸다.

float cosAngleSunToNormal = dot(vNormal, sunDir); // Compute cosine sun to normal
float mixAmountTexture = 1. / (1. + exp(-20. * cosAngleSunToNormal)); // Convert [+1, -1] -> [1, 0] with high contrast
    
// Combine night and day colors
vec3 color = mix(nightColor, dayColor, mixAmountTexture); // Select day or night texture

위에서 보듯이, mixAmountTexture는 값을 [+1, -1]에서 [1, 0]으로 변환할 뿐만 아니라, 자연스러운 낮밤 셰이딩을 위해 지수 함수를 사용하여 대비를 높인다. 그런 다음 mix() 함수가 밤 색상과 낮 색상을 섞는다. 이 기본 함수들은 앞서 간략히 설명하였다3.

여기까지의 결과는 다음과 같다.

uniform sampler2D u_dayTexture;
uniform sampler2D u_nightTexture;
uniform vec3 u_sunRelPosition; // the relative position of light source

varying vec2 vUv; // texture UV map
varying vec3 vNormal; // normal vector at surface

void main( void ) {
    vec3 sunDir = normalize(u_sunRelPosition);

    // Day and night texture with eclipse
    vec3 dayColor = texture2D( u_dayTexture, vUv ).rgb;
    vec3 nightColor = texture2D( u_nightTexture, vUv ).rgb;

    float cosAngleSunToNormal = dot(vNormal, sunDir); // Compute cosine sun to normal
    float mixAmountTexture = 1. / (1. + exp(-20. * cosAngleSunToNormal));
    
    // Combine night and day colors
    vec3 color = mix( nightColor, dayColor, mixAmountTexture ); // Select day or night texture

    gl_FragColor = vec4(color, 1.);
}

지형의 입체감 표현

우리의 지구 모델은 이상적인 구로 만들어졌기 때문에, 표면이 매우 매끄러워 보인다. 하지만 실제로는 산맥이 주변에 복잡한 그림자를 드리우며, 울퉁불퉁한 지형의 그림자 표현은 텍스처에 입체감을 더한다. 이를 표현하기 위해 법선 맵 텍스처 u_normalTexture를 사용한다.

이전 글1에서 언급했듯이, 법선 맵의 단일 RGB 값은 다음과 같이 정의된다.

\[R = (n_x + 1) / 2,\] \[G = (n_y + 1) / 2,\] \[B = (n_z + 1) / 2.\]

따라서 법선 벡터의 각 원소는

\[n_x = 2 * R - 1,\] \[n_y = 2 * G - 1,\] \[n_z = 2 * B - 1.\]

이다. 셰이더에서는 다음과 같이 사용한다.

vec3 t_normal = texture2D( u_normalTexture, vUv ).xyz * 2.0 - 1.0;

texture2D의 법선 벡터는 오브젝트의 지역 좌표계를 기준으로 한다. 법선 벡터를 지역 좌표계에서 월드 좌표계로 변환하기 위해 법선 벡터에 vTbn 행렬을 곱한다. vTbn 행렬의 각 열은 월드 좌표계 기준의 접선(tangent), 법선(normal), 그리고 두 벡터의 외적 벡터로 구성되며, Three.js가 제공하는 속성으로부터 계산할 수 있다. 따라서 월드 좌표계에서의 법선 벡터는

vec3 normal = normalize(vTbn * t_normal);

이다.

그리고 법선 벡터와 햇빛 방향 사이의 각도는

float cosAngleSunToSurface = dot(normal, sunDirUnit); // Compute cosine sun to normal

로 계산된다.

마지막으로, cosAngleSunToSurfacecosAngleSunToNormal의 차이를 mixAmountTexture 계산에 더한다. 아래는 전체 코드다.

// Normal map texture
vec3 t_normal = texture2D( u_normalTexture, vUv ).xyz * 2.0 - 1.0;
vec3 normal = normalize(vTbn * t_normal);
float cosAngleSunToSurface = dot(normal, sunDir); // Compute cosine sun to normal
mixAmountTexture *= 1.0 + u_normalPower * (cosAngleSunToSurface - cosAngleSunToNormal);
mixAmountTexture = clamp(mixAmountTexture, 0., 1.);

지표면 반사

태양빛의 반사와 그것이 카메라에 미치는 효과를 계산하려면 반사율 맵과 카메라의 위치가 필요하다. 반사율 맵의 예시는 다음과 같다.

반사 효과를 부드럽게 하기 위해 값의 범위를 제한하였다.

float reflectRatio = texture2D(u_specTexture, vUv).r;
reflectRatio = 0.3 * reflectRatio + 0.1; // [0, 1] -> [0.1, 0.4]

그러면 표면에서 반사되는 햇빛 벡터는 다음과 같이 계산된다.

vec3 reflectVec = -reflect(sunDir, normal); // reflected vector of sunlight

여기서 reflect(v, w) = \(v - 2 * (v \cdot w) * w\)이다.

결국, 반사 벡터가 카메라 위치에서 표면 위치로 향하는 벡터에 가까운 표면 점일수록 밝아져야 한다. cameraPosition은 Three.js의 내장 속성이며, 월드 좌표계에서 카메라의 위치를 나타낸다는 점에 유의한다.

float specPower = dot(reflectVec, normalize(cameraPosition - surfacePosition)); // dot product between reflected light and camera vector. When they are close to each other, the result of dot product increases.
color += mixAmountTexture * pow(specPower, 2.0) * reflectRatio;

반사율 맵 텍스처 부분은 아래와 같이 정리된다.

// Specular map texture with reflection
vec3 surfacePosition = u_position + vPosition; // surface position in world coordinates
float reflectRatio = texture2D(u_specTexture, vUv).r;
reflectRatio = 0.3 * reflectRatio + 0.1;
vec3 reflectVec = reflect(-sunDir, normal); // reflected vector of sunlight
float specPower = clamp(dot(reflectVec, normalize(cameraPosition - surfacePosition)), 0., 1.); // dot product between reflected light and camera vector
color += mixAmountTexture * pow(specPower, 2.0) * reflectRatio;

구름

지구에는 대기 순환으로 인해 구름이 존재한다. 구름 텍스처 맵은 온라인에서 찾을 수 있다.

이 절에서는 구름과 그 그림자를 렌더링한다.

구름 층은 맨 위에 있으므로, 이전 텍스처와 구름 텍스처를 그냥 겹쳐도 된다. 위 PNG 파일은 RGBA 값을 제공하며, 알파 채널의 값을 mix 함수의 가중치로 사용할 수 있다.

vec4 cloudsColor = texture2D(u_cloudTexture, vUv);
cloudsColor *= clamp(mixAmountHemisphere, 0.2, 1.);
color = color * (1.0 - cloudsColor.a) + cloudsColor.rgb * cloudsColor.a;

단, 구름의 낮과 밤을 구현하기 위해 mixAmountTexture 대신 새로운 값 mixAmountHemisphere를 사용했다는 점에 유의한다. mixAmountTexture에는 지형의 입체감 계수가 포함되어 있기 때문이다.

float mixAmountHemisphere = 1. / (1. + exp(-20. * cosAngleSunToNormal));

더 사실적인 구름을 렌더링하기 위해, 예를 들어 낮과 밤의 경계에서 빨간색은 서서히 줄어들게 하고 파란색은 급격히 어두워지게 하는 식으로 cloudColor의 RGB 값을 독립적으로 조정할 수 있다. 이렇게 해질녘 노을의 모습을 표현할 수 있다.

cloudsColor.r *= clamp(mixAmountHemisphere, 0.2, 1.);
cloudsColor.g *= clamp(pow(mixAmountHemisphere, 1.5), 0.2, 1.);
cloudsColor.b *= clamp(pow(mixAmountHemisphere, 2.0), 0.2, 1.); // Blue light is less scattered than red light
cloudsColor.a *= clamp(mixAmountHemisphere, 0.1, 1.);

구름 층은 지표면보다 높은 곳에 위치해있기 때문에, 구름에 입체감을 주기 위해서 구름의 그림자를 계산할 수 있다. 먼저 텍스처 좌표에서 dot(vNormal, sunDir) * vNormal - sunDir를 계산한다. 이는 표면과 평행한 벡터로, 표면 텍스처 UV 위치에서 햇빛이 투과해 들어가는 구름 텍스처 UV 맵상의 위치를 가리킨다.

그런 다음 inverse(vTbn)이 벡터를 월드 좌표계에서 지역 좌표계로 변환하며, 매직 넘버 0.005는 구름의 높이 정도를 의미한다. 다음으로 cloudsShadow는 표면에 영향을 주는 구름 텍스처 값이다. 따라서 햇빛이 투과하는 구름의 두께에 따라 mixAmountTexture가 감소한다.

vec3 translVec = 0.0005 * inverse(vTbn) * (dot(vNormal, sunDir) * vNormal - sunDir);
vec4 cloudsShadow = texture2D(u_cloudTexture, vUv - translVec.xy);
mixAmountTexture *= (1. - 0.5*cloudsShadow.a);

식 (蝕)

이전 글에서도 지구 표면에 달의 식 그림자를 렌더링했다. 그러나 Three.js의 그림자 맵은 구면 좌표계를 기반으로 하므로, 광원으로부터의 거리가 멀어지면 해상도가 떨어진다. 또한 넓은 우주 공간에는 물체가 거의 없으므로, Three.js의 그림자 맵을 사용하는 것은 메모리 비효율적이다. 따라서 셰이더를 이용해 객체에 직접 그림자를 렌더링한다. 이 방법을 사용하면 광원으로부터의 거리가 멀어져도 그림자의 품질이 저하되지 않는다.

아래 그림은 일식을 고려하여 표면의 밝기를 계산하는 방법을 설명한다.

태양과 달의 모양은 구로 단순화하고, 태양을 2차원으로 바라보았을 때, 각 위치(중앙이나 주변부)에서 나오는 빛의 세기는 모두 동일하다고 가정하였다. 태양과 달의 실제 반지름을 각각 \(R_s\)와 \(R_m\)이라 하자. 지구에서 태양과 달로 향하는 벡터를 각각 \({v}_{se}\)와 \({v}_{me}\)라 할 때, 태양과 달의 겉보기 반지름 \(\theta_{s}\)와 \(\theta_{m}\)은

\[\theta_{s} = \sin^{-1} {\frac{R_s}{d_{se}}} \approx \frac{R_s}{d_{se}}\]

\[\theta_{m} = \sin^{-1} {\frac{R_m}{d_{me}}} \approx \frac{R_m}{d_{me}},\]

이며, 여기서 \(d_{se}=\|{v}_{se}\|\), \(d_{me}=\|{v}_{me}\|\)이다. 또한 태양과 달 사이의 겉보기 거리는 \(\theta_{sm} = \frac{ {v}_{se} \cdot {v}_{me}}{ \|{v}_{se}\| * \|{v}_{me}\|}\)이다. 그러면 표면의 밝기는 \(\theta_s, \theta_m\), \(\theta_{sm}\) 사이의 관계에 따라 달라진다. \(\theta_{sm}\)이 \(\theta_s + \theta_m\)보다 클 때는 식이 나타나지 않는다. 이 경우 광원의 겉보기 면적은 \(\pi R_s^2\)이며, \(\pi R_s^2\)로 스케일링하여 셰이더에는 1로 적용된다. 이 스케일링된 값을 밝기 계수(brightness factor)라 부르자.

\(\theta_m < \theta_s\)이고 \(0 < \theta_{sm} < \theta_s - \theta_m\)을 만족하면, 달이 완전히 태양 안에 들어가므로 밝기 계수는

\[\frac{\pi R_s^2 - \pi (d_{se}\theta_m)^2}{\pi R_s^2} = 1 - \frac{\theta^2_m}{\theta^2_s},\]

이며, 여기서 \(d_{se}\theta_m\)은 달의 실제 반지름이 아니라 달과 태양이 지구로부터 같은 거리에 있다고 가정한 겉보기 반지름을 가리킨다.

또한 \(\theta_s < \theta_m\)이고 \(0 < \theta_{sm} < \theta_m - \theta_s\)를 만족하면, 태양이 달에 완전히 가려지므로 밝기 계수는 0이다. 그 외에 \(abs(\theta_s - \theta_m) < \theta_{sm} < \theta_s + \theta_m\)을 만족하면 태양이 부분적으로 가려진다. 이 경우 밝기 계수는

\[\frac{1}{\pi \theta^2_s} (\pi \theta^2_s - \phi_s \theta^2_s + \frac{1}{2}\theta^2_s\sin(2\phi_s) -\phi_m \theta^2_m + \frac{1}{2}\theta^2_m \sin(2 \phi_m)),\]

이며, 여기서

\[\begin{align} \phi_s &= \cos^{-1}(x/\theta_s), \nonumber\\ \phi_m &= \cos^{-1}((\theta_{sm}-x)/\theta_m), \end{align}\]

이고,

\[x = \frac{1}{2\theta_{sm}} * (\theta_{sm}^2 + \theta_s^2 - \theta_m^2)\]

\[x^2 + y^2 = \theta_s^2\]

\[(x-\theta_{sm})^2 + y^2 = \theta_m^2\]

의 교차 위치다.

위 식들은 아래 함수로 정리된다.

#define PI (3.141592)

float eclipse(float angleBtw, float angleLight, float angleObs) {
    float angleRatio2 = pow(angleObs / angleLight, 2.);
    float value;
    if (angleBtw > angleLight - angleObs && angleBtw < angleLight + angleObs) {
        if (angleBtw < angleObs - angleLight) {
            value = 0.;
        }else {
            float x = 0.5/angleBtw * (angleBtw*angleBtw + angleLight*angleLight - angleObs*angleObs);
            float ths = acos(x/angleLight);
            float thm = acos((angleBtw-x)/angleObs);
            value = 1./PI * (PI - ths + 0.5 * sin(2. * ths) - thm * angleRatio2 + 0.5 * angleRatio2 * sin(2. * thm));
        }
    } else if (angleBtw > angleLight + angleObs)
        value = 1.;
    else { // angleBtw < angleLight - angleObs
        value = 1. - angleRatio2;
    }

    return clamp(value, 0., 1.);
}

그러면 표면의 밝기는 모든 달에 대한 밝기 계수와 곱해진다.

// Eclipse
vec3 surfacePosition = u_position + vPosition;
float distSurfaceToSun = length(u_sunRelPosition);
float cosAngleBtwSunMoon = dot(sunDir, normalize(u_moonPosition - surfacePosition));
float angleBtwSunMoon = acos(cosAngleBtwSunMoon);
float distSurfaceToMoon = length(u_moonPosition - surfacePosition);

mixAmountHemisphere *= eclipse(angleBtwSunMoon, asin(u_sunRadius/distSurfaceToSun), asin(u_moonRadius/distSurfaceToMoon));

식 그림자를 지면에 적용하려면 법선 맵 처리 부분에 아래 줄을 삽입한다.

mixAmountTexture *= mixAmountHemisphere;

대기

지구에는 대기가 있다. 우리의 파란 대기를 생성하려면, 대기가 빛을 등지고 있을때 두드러지게 보인다는 점을 고려해야 한다. 따라서 태양과 법선 벡터 사이의 코사인 각을 다시 계산한다. 그런 다음 빛나는 태양 만들기의 글로우 효과와 마찬가지로, u_color로 주변광 원을 만들고 mixAmount로 마스킹했다. vNormalView는 카메라 좌표계로 표현된 오브젝트의 법선 벡터를 나타낸다는 점에 유의한다.

uniform vec3 u_sunRelPosition;
uniform vec3 u_color; // the color of atmosphere

varying vec3 vNormal;
varying vec3 vNormalView;
varying vec3 vPosition;

void main( void ) {
    vec3 sunDir = u_sunRelPosition;
    vec3 sunDirUnit = normalize(sunDir);

    // Day and night texture
    float cosAngleSunToNormal = dot(vNormal, sunDirUnit); // Compute cosine sun to normal
    float mixAmount = 1. / (1. + exp(-7. * (cosAngleSunToNormal + 0.1))); // Sharpen the edge beween the transition

    // Atmosphere
    float raw_intensity = 3. * max(dot(vPosition, vNormalView), 0.);
    float intensity = pow(raw_intensity, 3.);
    
    gl_FragColor = vec4(u_color, intensity) * mixAmount;
}

결과는 다음과 같다. 예제서는, 대기를 더 알아보기 쉽도록 배경색을 검은색으로 바꾸었다.

프레넬

드디어 마지막 단계이다. 프레넬(Fresnel) 효과는 입사각에 따른 반사 강도를 다룬다. 입사각이 90도에 가까워질수록 반사가 강해진다. 따라서 지구 중심에서 멀어질수록, 즉 카메라-표면 벡터와 표면 법선 벡터 사이의 각이 클수록 대기는 덜 투명해야 한다. 위 대기와 마찬가지로, 프레넬 값도 낮과 밤의 양 mixAmount로 마스킹된다.

uniform vec3 u_sunRelPosition;
uniform vec3 u_color;

varying vec3 vNormal;
varying vec3 vNormalView;
varying vec3 vPosition;

void main() {
    vec3 sunDir = u_sunRelPosition;
    vec3 sunDirUnit = normalize(sunDir);

    // Day and night texture with eclipse
    float cosAngleSunToNormal = dot(vNormal, sunDirUnit); // Compute cosine sun to normal
    float mixAmount = 1. / (1. + exp(-7. * (cosAngleSunToNormal + 0.1))); // Sharpen the edge beween the transition

    float fresnelTerm = (1. + dot(normalize(vPosition), normalize(vNormalView)));
    fresnelTerm = pow(fresnelTerm, 2.0);
    
    gl_FragColor = vec4( u_color, 1. ) * fresnelTerm * mixAmount;
}

아래 이미지를 이전 이미지와 비교하면, 지구 가장자리에서 표면이 흐려지는 것을 볼 수 있다.

전체 코드

여기까지의 전체 프래그먼트 셰이더와 정점 셰이더 코드, 그리고 Three.js JavaScript 코드는 다음과 같다.

지구의 프래그먼트 셰이더

uniform sampler2D u_dayTexture;
uniform sampler2D u_nightTexture;
uniform sampler2D u_normalTexture;
uniform sampler2D u_specTexture;
uniform sampler2D u_cloudTexture;
uniform vec3 u_sunRelPosition; // the relative position of light source
uniform float u_normalPower;
uniform vec3 u_position;
uniform vec3 u_moonPosition;
uniform float u_moonRadius;
uniform float u_sunRadius;

varying mat3 vTbn;
varying vec2 vUv; // texture UV map
varying vec3 vNormal; // normal vector at surface
varying vec3 vPosition;

#define PI (3.141592)

float eclipse(float angleBtw, float angleLight, float angleObs) {
    float angleRatio2 = pow(angleObs / angleLight, 2.);
    float value;
    if (angleBtw > angleLight - angleObs && angleBtw < angleLight + angleObs) {
        if (angleBtw < angleObs - angleLight) {
            value = 0.;
        }else {
            float x = 0.5/angleBtw * (angleBtw*angleBtw + angleLight*angleLight - angleObs*angleObs);
            float ths = acos(x/angleLight);
            float thm = acos((angleBtw-x)/angleObs);
            value = 1./PI * (PI - ths + 0.5 * sin(2. * ths) - thm * angleRatio2 + 0.5 * angleRatio2 * sin(2. * thm));
        }
    } else if (angleBtw > angleLight + angleObs)
        value = 1.;
    else { // angleBtw < angleLight - angleObs
        value = 1. - angleRatio2;
    }

    return clamp(value, 0., 1.);
}

void main( void ) {
    vec3 sunDir = normalize(u_sunRelPosition);

    // Day and night texture with eclipse
    vec3 dayColor = texture2D( u_dayTexture, vUv ).rgb;
    vec3 nightColor = texture2D( u_nightTexture, vUv ).rgb;

    float cosAngleSunToNormal = dot(vNormal, sunDir); // Compute cosine sun to normal
    float mixAmountTexture = 1. / (1. + exp(-20. * cosAngleSunToNormal));
    float mixAmountHemisphere = mixAmountTexture;
    
    // 2. Eclipse
    vec3 surfacePosition = u_position + vPosition;
    float distSurfaceToSun = length(u_sunRelPosition);
    float cosAngleBtwSunMoon = dot(sunDir, normalize(u_moonPosition - surfacePosition));
    float angleBtwSunMoon = acos(cosAngleBtwSunMoon);
    float distSurfaceToMoon = length(u_moonPosition - surfacePosition);

    mixAmountHemisphere *= eclipse(angleBtwSunMoon, asin(u_sunRadius/distSurfaceToSun), asin(u_moonRadius/distSurfaceToMoon));

    // Normal map texture
    vec3 t_normal = texture2D( u_normalTexture, vUv ).xyz * 2.0 - 1.0;
    vec3 normal = normalize(vTbn * t_normal);
    float cosAngleSunToSurface = dot(normal, sunDir); // Compute cosine sun to normal
    mixAmountTexture *= 1.0 + u_normalPower * (cosAngleSunToSurface - cosAngleSunToNormal);
    mixAmountTexture *= mixAmountHemisphere;
    mixAmountTexture = clamp(mixAmountTexture, 0., 1.);

    // Cloud shadow
    vec3 translVec = 0.0005 * inverse(vTbn) * (vNormal - sunDir);
    vec4 cloudsShadow = texture2D(u_cloudTexture, vUv - translVec.xy);
    mixAmountTexture *= (1. - 0.5*cloudsShadow.a);

    // Combine night and day colors
    vec3 color = mix( nightColor, dayColor, mixAmountTexture ); // Select day or night texture

    // Specular map texture with reflection
    float reflectRatio = texture2D(u_specTexture, vUv).r;
    reflectRatio = 0.3 * reflectRatio + 0.1;
    vec3 reflectVec = reflect(-sunDir, normal); // reflected vector of sunlight
    float specPower = clamp(dot(reflectVec, normalize(cameraPosition - surfacePosition)), 0., 1.); // dot product between reflected light and camera vector
    color += mixAmountTexture * pow(specPower, 2.0) * reflectRatio;

    // cloud
    vec4 cloudsColor = texture2D(u_cloudTexture, vUv);
    cloudsColor.r *= clamp(mixAmountHemisphere, 0.2, 1.);
    cloudsColor.g *= clamp(pow(mixAmountHemisphere, 1.5), 0.2, 1.);
    cloudsColor.b *= clamp(pow(mixAmountHemisphere, 2.0), 0.2, 1.); // Blue light is less scattered than red light
    cloudsColor.a *= clamp(mixAmountHemisphere, 0.1, 1.);
    color = color * (1.0 - cloudsColor.a) + cloudsColor.rgb * cloudsColor.a;

    // render
    gl_FragColor = vec4(color, 1.);
}

지구의 정점 셰이더

varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosition;
varying mat3 vTbn;

attribute vec4 tangent; // "geometry.computeTangents()" is needed.

void main() {
    vUv = uv;
    vNormal = normalize(mat3(modelMatrix) * normal);
    vPosition = mat3(modelMatrix) * position;
    
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

    vec3 t = normalize(tangent.xyz);
    vec3 n = normalize(normal.xyz);
    vec3 b = normalize(cross(t, n));

    t = mat3(modelMatrix) * t;
    b = mat3(modelMatrix) * b;
    n = mat3(modelMatrix) * n;
    vTbn = mat3(t, b, n);
}

대기의 정점 셰이더

varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vNormalModel;
varying vec3 vNormalView;
varying vec3 vPosition;

void main() {
    vUv = uv;
    vNormal = normalize(mat3(modelMatrix) * normal);
    vNormalModel = normal;
    vNormalView = normalize(normalMatrix * normal);
    vPosition = normalize(vec3(modelViewMatrix * vec4(position, 1.0)).xyz);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

main.js

import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import vertex from PATH_TO_EARTH_VERTEX;
import fragment from PATH_TO_EARTH_FRAGMENT;
import addon_vertex from PATH_TO_ADDON_VERTEX;
import atmosphere from PATH_TO_ATMOSPHERE_FRAGMENT;
import fresnel from PATH_TO_FRESNEL_FRAGMENT;

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);

const renderer = new THREE.WebGLRenderer({canvas: canvas, alpha: true, antialias: true});
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.shadowMap.enabled = true;

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 5);

const geometry_sphere = new THREE.SphereGeometry(1, 30, 30);
geometry_sphere.computeTangents();

const material_sun = new THREE.MeshBasicMaterial({color: 0xffaa00, opacity: 0, transparent: true});
const sun = new THREE.Mesh(geometry_sphere, material_sun);

const material_earth = new THREE.ShaderMaterial({
    uniforms: {
        u_dayTexture: { value: new THREE.TextureLoader().load( './assets/2k_earth_daymap.jpg') },
        u_nightTexture: { value: new THREE.TextureLoader().load( './assets/earthlights2k.jpg') },
        u_normalTexture: { value: new THREE.TextureLoader().load( './assets/earthnormal2k.jpg') },
        u_specTexture: { value: new THREE.TextureLoader().load( './assets/2k_earth_specular_map.tif') },
        u_cloudTexture: { value: new THREE.TextureLoader().load( './assets/earthcloud.png')},
        u_normalPower: { value: 5.0 },
        u_sunRelPosition: { value: new THREE.Vector3(0,0,0)},
        u_position: { value: new THREE.Vector3(0,0,0)},
        u_moonPosition: { value: new THREE.Vector3(0,0,0)},
        u_moonRadius: { value: 0.05},
        u_sunRadius: { value: 0.2},
    },
    vertexShader: vertex,
    fragmentShader: fragment,
})
const earth = new THREE.Mesh(geometry_sphere, material_earth);
earth.scale.set(0.2, 0.2, 0.2);

const material_earth_atmosphere = new THREE.ShaderMaterial({
    uniforms: {
        u_sunRelPosition: { value: new THREE.Vector3(0,0,0)},
        u_color: { value: new THREE.Vector3(.45,.55,1)},
    },
    vertexShader: addon_vertex,
    fragmentShader: atmosphere,
    transparent: true,
    side: THREE.BackSide,
    depthTest: true,
    depthWrite: false,
});

const earth_atmosphere = new THREE.Mesh(geometry_sphere, material_earth_atmosphere);
earth_atmosphere.scale.set(1.05, 1.05, 1.05);
earth.add(earth_atmosphere);

const material_earth_fresnel = new THREE.ShaderMaterial({
    uniforms: {
        u_sunRelPosition: { value: new THREE.Vector3(0,0,0)},
        u_color: { value: new THREE.Vector3(.45,.55,1)},
    },
    vertexShader: addon_vertex,
    fragmentShader: fresnel,
    transparent: true
});

const earth_fresnel = new THREE.Mesh(geometry_sphere, material_earth_fresnel);
earth_fresnel.scale.set(1.0001, 1.0001, 1.0001);;
earth.add(earth_fresnel);

const material_moon = new THREE.MeshLambertMaterial();
const moon = new THREE.Mesh(geometry_sphere, material_moon);
moon.scale.set(0.05, 0.05, 0.05);

const loader = new THREE.TextureLoader();
loader.load('./assets/moonmap2k.jpg', (texture)=>{
    material_moon.map = texture;
    material_moon.needsUpdate = true;
});

const light = new THREE.PointLight(0xffffff, 15);
light.position.set(0, 0, 0);
light.castShadow = true;
// light.shadow.mapSize = new THREE.Vector2(4096, 4096);
// light.shadow.radius = 20;

const earth_orbit = new THREE.Object3D();
const earth_equator = new THREE.Object3D();
const moon_orbit = new THREE.Object3D();
// earth_equator.rotateZ(23.5*Math.PI/180);

const scene = new THREE.Scene();
scene.add(sun);
sun.add(light);
scene.add(earth_orbit);
earth_orbit.add(earth_equator);
earth_equator.add(earth);
earth_equator.add(moon_orbit);
moon_orbit.add(moon);

const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

camera.position.set(0,0,0.6)

const w_moon = 0.5;
const w_orbit = 0;
const w_rotate = 0.0;

function updateSystem(sec) {
    moon.position.set(0.4*Math.cos(w_moon*sec), 0, -0.4*Math.sin(w_moon*sec));
    sun.position.set(3*Math.cos(w_orbit*sec), 0, -3*Math.sin(w_orbit*sec));
    material_earth.uniforms.u_sunRelPosition.value.x = 3*Math.cos(w_orbit*sec);
    material_earth.uniforms.u_sunRelPosition.value.y = 0;
    material_earth.uniforms.u_sunRelPosition.value.z = -3*Math.sin(w_orbit*sec);
    material_earth.uniforms.u_moonPosition.value.x = 0.4*Math.cos(w_moon*sec);
    material_earth.uniforms.u_moonPosition.value.y = 0;
    material_earth.uniforms.u_moonPosition.value.z = -0.4*Math.sin(w_moon*sec);
    material_earth_atmosphere.uniforms.u_sunRelPosition.value.x = 3*Math.cos(w_orbit*sec);
    material_earth_atmosphere.uniforms.u_sunRelPosition.value.y = 0;
    material_earth_atmosphere.uniforms.u_sunRelPosition.value.z = -3*Math.sin(w_orbit*sec);
    material_earth_fresnel.uniforms.u_sunRelPosition.value.x = 3*Math.cos(w_orbit*sec);
    material_earth_fresnel.uniforms.u_sunRelPosition.value.y = 0;
    material_earth_fresnel.uniforms.u_sunRelPosition.value.z = -3*Math.sin(w_orbit*sec);
    // earth.rotateY(w_rotate);
    sun.rotateY(w_rotate);
}

function animate (msec) {
    requestAnimationFrame(animate);
    
    updateSystem(msec * 0.001);

    controls.update();
    renderer.render(scene, camera);
}
animate();