Earth and planes

출처: Three.js in practice - Earth and planes - tutorial for beginners 2022

html에 밝은 배경과 어두운 배경을 만들고 어두운 배경은 opacity를 0으로 둔다.

기본 Three.js Scene을 설정하되 Renderer에 alpha: true옵션을 추가해 뒷배경이 보이도록 설정해준다.

구를 하나 추가해주고 OrbitsControl을 추가해준다. 다음과 같은 것을 볼 수 있다.

result

https://github.com/Domenicobrz/Threejs-in-practice/tree/main/three-in-practice-3/assets 의 에셋들을 다운받은 뒤, 구에 텍스처를 추가한다.

let textures = {
  bump: await new TextureLoader().loadAsync("assets/earthbump.jpg"),
  map: await new TextureLoader().loadAsync("assets/earthmap.jpg"),
  spec: await new TextureLoader().loadAsync("assets/earthspec.jpg"),
};

let sphere = new Mesh(
  new SphereGeometry(10, 70, 70),
  new MeshPhysicalMaterial({
    map: textures.map,
    roughnessMap: textures.spec,
    bumpMap: textures.bump,
    bumpScale: 0.05,
    sheen: 1,
    sheenRoughness: 0.75,
    sheenColor: new Color("#ff8a00").convertSRGBToLinear(),
    clearcoat: 0.5,
  })
);

구에 지구 텍스처가 입혀졌다.

result

하지만 뒷편으로 돌리면 검은색만 나오기 때문에 environment map을 추가해줘야 한다.

let pmrem = new PMREMGenerator(renderer);
let envmapTexture = await new RGBELoader()
  .setDataType(FloatType)
  .loadAsync("assets/old_room_2k.hdr");
let envMap = pmrem.fromEquirectangular(envmapTexture).texture;

let sphere = new Mesh(
  new SphereGeometry(10, 70, 70),
  new MeshPhysicalMaterial({
    ...,
    envMap,
    envMapIntensity: 0.4,
  })
);
sphere.rotation.y += Math.PI * 1.25;

envmap이 추가되어 검은 부분도 자연스러워지는 것을 볼 수 있다.

result


다음으로는 비행기를 띄워보겠다. 이전 링크에서 plane/scene.glb 파일과 mask.png 파일을 다운로드한 뒤 GLTFLoader를 이용하여 불러온다.

let plane = (await new GLTFLoader().loadAsync("assets/plane/scene.glb")).scene
  .children[0];
let planesData = [makePlane(plane, textures.planeTrailMask, envMap, scene)];

비행기를 만드는 함수도 만들어준다.

function makePlane(planeMesh, trailTexture, envMap, scene) {
  let plane = planeMesh.clone();
  plane.scale.set(0.001, 0.001, 0.001);
  plane.position.set(0, 0, 0);
  plane.rotation.set(0, 0, 0);
  plane.updateMatrixWorld();

  plane.traverse((object) => {
    if (object instanceof Mesh) {
      object.material.envMap = envMap;
      object.castShadow = true;
      object.receiveShadow = true;
    }
  });

  let group = new Group();
  group.add(plane);

  scene.add(group);

  return {
    group,
    yOff: 10.5 + Math.random() * 1.0,
  };
}

그 후 animationLoop에 추가해준다.

planesData.forEach((planeData) => {
  let plane = planeData.group;

  plane.position.set(0, 0, 0);
  plane.rotation.set(0, 0, 0);
  plane.updateMatrixWorld();

  plane.translateY(planeData.yOff);
  plane.rotateOnAxis(new Vector3(1, 0, 0), +Math.PI * 0.5);
});

그런 뒤 확대해보면 비행기가 하나 떠있는 것을 발견할 수 있다.

result

비행기를 구 중심으로부터 랜덤한 yOffset으로 띄워놓았기 때문에 일정 구간을 돌도록 만들어주어야 한다.

먼저 makePlane 함수의 리턴에 다음을 추가해준다.

return {
  group,
  rot: 0,
  rad: 0.5,
  yOff: 10.5 + Math.random() * 1.0,
};

이후 Clock으로 delta를 구해준다.

let clock = new Clock();

renderer.setAnimationLoop(() => {
  let delta = clock.getDelta();
  ...
});

delta에 따라 y축으로 회전하고 그에 따라 x, z축도 회정하도록 만들어준다.

planesData.forEach((planeData) => {
  let plane = planeData.group;

  plane.position.set(0, 0, 0);
  plane.rotation.set(0, 0, 0);
  plane.updateMatrixWorld();

  planeData.rot += delta * 0.25;
  plane.rotateOnAxis(new Vector3(0, 1, 0), planeData.rot);
  plane.rotateOnAxis(new Vector3(0, 0, 1), planeData.rad);
  plane.translateY(planeData.yOff);
  plane.rotateOnAxis(new Vector3(1, 0, 0), +Math.PI * 0.5);
});

일정 궤도를 회전하는 비행기가 완성되었다.

result

이제 비행기가 랜덤하게 움직이도록 값들을 추가해준다.

// makePlane return statement
return {
  group,
  rot: Math.random() * Math.PI * 2.0,
  rad: Math.random() * Math.PI * 0.45 + 0.2,
  yOff: 10.5 + Math.random() * 1.0,
  randomAxis: new Vector3(nr(), nr(), nr()).normalize(),
  randomAxisRot: Math.random() * Math.PI * 2,
};

// nr function
function nr() {
  return Math.random() * 2 - 1;
}

// animation Loop
planesData.forEach((planeData) => {
  ...
  planeData.rot += delta * 0.25;
  plane.rotateOnAxis(planeData.randomAxis, planeData.randomAxisRot);
  ...
})

다음에는 비행기의 잔상(트레일)을 추가해보겠다. makePlane함수에 trail을 추가한다.

let trail = new Mesh(
  new PlaneGeometry(1, 2),
  new MeshPhysicalMaterial({
    envMap,
    envMapIntensity: 3,

    // roughness: 0.4,
    // metalness: 0,
    // transmission: 1,

    transparent: true,
    opacity: 1,
    alphaMap: trailTexture,
  })
);
trail.rotateX(Math.PI);
trail.translateY(1.1);

...

group.add(trail);

...

비행기를 멈춰보면 트레일이 생긴 것을 볼 수 있다.

result

비행기들을 추가하고 원래대로 이동하도록 변경하면 다음과 같은 것을 볼 수 있다.

result

다음으로느 지구 주변의 원 효과를 위해 새로운 Scene과 카메라를 추가해준다.

const ringScene = new Scene();

const ringCamera = new PerspectiveCamera(
  45,
  innerWidth / innerHeight,
  0.1,
  1000
);
ringCamera.position.set(0, 0, 50);

이전 Clock과 같이 ring을 세개 추가해주고 렌더링 함수에 ringScene도 렌더링하도록 만들어준다.

renderer.autoClear = false;
renderer.render(ringScene, ringCamera);
renderer.autoClear = true;

result


마지막으로 낮과 밤을 만들어보겠다. 현재 light 외의 moonlight를 만들어 씬에 추가해준다. 그리고 animejs 모듈을 추가한다. 그 후 init 함수에 anime를 추가해준다.

window.addEventListener("keypress", (e) => {
  if (e.key !== "j") return;

  anime({
    targets: sunBackground,
    opacity: [0, 1],
    easing: "easeInOutSine",
    duration: 500,
  });
});

이제 키보드의 J를 누르면 배경이 하얘지는 것을 볼 수 있다.

result

sphere과 plane에 intensity 속성을 추가한다.

sphere.sunEnvIntensity = 0.4;
sphere.moonEnvIntensity = 0.1;

// makePlane function
object.sunEnvIntensity = 1;
object.moonEnvIntensity = 0.3;

trail.sunEnvIntensity = 3;
trail.moonEnvIntensity = 0.7;

// rings
ring1.sunOpacity = 0.35;
ring1.moonOpacity = 0.03;

ring2.sunOpacity = 0.35;
ring2.moonOpacity = 0.1;

ring3.sunOpacity = 0.35;
ring3.moonOpacity = 0.03;

마지막으로 anime를 수정한다 (조금 복잡할 수 있다.)

let daytime = true;
let animating = false;

window.addEventListener("mousemove", (e) => {
  if (animating) return;

  let anim;
  if (e.clientX > innerWidth - 200 && !daytime) {
    anim = [1, 0];
  } else if (e.clientX < 200 && daytime) {
    anim = [0, 1];
  } else {
    return;
  }

  animating = true;

  let obj = { t: 0 };
  anime({
    targets: obj,
    t: anim,
    complete: () => {
      animating = false;
      daytime = !daytime;
    },
    update: () => {
      sunLight.intensity = 3.5 * (1 - obj.t);
      moonLight.intensity = 3.5 * obj.t;

      sunLight.position.setY(20 * (1 - obj.t));
      moonLight.position.setY(20 * obj.t);

      sphere.material.sheen = 1 - obj.t;

      scene.children.forEach((child) => {
        child.traverse((object) => {
          if (object instanceof Mesh && object.material.envMap) {
            object.material.envMapIntensity =
              object.sunEnvIntensity * (1 - obj.t) +
              object.moonEnvIntensity * obj.t;
          }
        });
      });

      ringScene.children.forEach((child, i) => {
        child.traverse((object) => {
          object.material.opacity =
            object.sunOpacity * (1 - obj.t) + object.moonOpacity * obj.t;
        });
      });

      sunBackground.style.opacity = 1 - obj.t;
      moonBackground.style.opacity = obj.t;
    },
    easing: "easeInOutSine",
    duration: 500,
  });
});

완성된 모습이다.

result