[JavaScript] 3D 스크롤 구현하기

김유진·2023년 3월 14일
3

Javascript

목록 보기
20/22

이제 CSS/JS 복습의 마무리에 다다랐다. 최종장인 3D 스크롤을 구현하는것을 연습하면서 그동안 진행했던 CSS/JS 학습을 총정리하면서 마무리를 하려고 한다.

1. 3D 벽 공간 구현하기

화면의 3D 시점은 perspective 라는 속성으로 인하여 결정된다.
먼저 스크롤을 통하여 캐릭터가 전진하는 것을 구현하고 싶은 것이므로, 전체 body의 높이를 500vw로 결정하여 스크롤을 충분히 할 수 있는 공간을 만들어 둔다.

body{
    height:500vw;
    font-family: Arial, Helvetica, sans-serif;
    color:#555;
    background: #fff000;
    -webkit-overflow-scrolling: touch;
    transition: 0.5s;
}

해당 공간에 3D 공간을 만들어야 하므로, stage 컨테이너를 만들어 3D 공간으로 만들어주고, 스크롤에 따라서 위치가 변하면 안되므로 positionfixed로 고정한다. 그리고 가로와 세로를 각각 100vw100vh 로 설정하여 우리 눈에 화면이 꽉 차서 보일 수 있도록 한다.

다음으로는 3D 모형으로 보이는 방 안을 구성할 차례인데,

아래 그림과 같이 가장 앞의 벽은 Z-index 를 두어 보이는 순서 차이가 존재하도록 만들고, 위 아래 벽을 이용하여 3D 입체 공간을 더욱 현실적으로 표현한다.
왼쪽 벽과 오른쪽 벽을 구현하는 것이 조금 어렵게 느껴질 수 있는데, Y축을 기준으로 90도 회전시켜 둔 다음 Z축으로 가로 벽의 길이만큼 이동 시키면 위와 같이 3D 모형으로 구현할 수 있다.이 때, Y축을 기준으로 회전시키면 축이 변화하기 때문에 Z축으로 이동하는 방향을 신경써야 한다는 것을 유의해야 한다. (왜 안돼?!!?) 하지 말기!

2. 마우스 두는 곳으로 시점 이동하기

다음으로는 위와 같이 마우스를 이동하는 곳에 따라서 시점이 이동하게끔 하는 것을 구현한다.
이 기능은 굳이 구현하지 않아도 되나, 이런 효과가 있으면 3D 공간에 와 있는 듯한 느낌을 극대화시킬 수 있으므로 구현하는 것이 좋다! 내가 구현하고자 하는 웹페이지가 조금 더 디테일하게 표현되는 효과를 준다.
해당 효과는 자바스크립트를 이용하여 줄 수 있는데,

window.addEventListener('mousemove', function(e){
        mousePos.x = -1 + (e.clientX / window.innerWidth) * 2;
        mousePos.y = 1 - (e.clientY / window.innerHeight) * 2;
        stageElem.style.transform = 'rotateX(' + mousePos.y  * 5 + 'deg) rotateY(' + mousePos.x * 5 +'deg)'
    })

mousemove 이벤트 핸들러 함수를 작성하여 효과를 줄 수 있다.
두번째, 세번째 줄과 같은 형식을 앞으로 많이 이용하게 될 수도 있는데, 정 가운데를 0,0으로 두고, 위 - 아래를 1 ~ -1 범위로 설정, 왼쪽 - 오른쪽을 -1 ~ +1 범위로 설정하여 stage 화면 전체를 회전시키도록 한 것이다.
지금은 각도를 적정한 각도로 조정한다고 2를 곱해놓은 상태인데, 더욱 크거나 작게 만들고 싶다면 배수를 조절할 수 있다.

window.innerWidth는 현재 브라우저의 가로 길이를 나타내는 것으로, body의 픽셀을 세는 것보다 변화하는 브라우저 창에 대하여 조금 더 자유롭게 대응할 수 있다.

3. 클릭하는 곳에 캐릭터 위치하게 하기

stageElem.addEventListener('click', function(e){
        new Character({
            xPos : e.clientX / window.innerWidth * 100,
            speed: Math.random() * 0.5 + 0.2
        });
    });

stage DOM 요소를 클릭할 때마다 캐릭터가 생성되도록 만드는 이벤트 핸들러이다. 캐릭터 객체를 만들어서 위치와 그의 스피드를 결정해 주는 것으로, 캐릭터가 생성되기 위해서 Character 객체 생성자를 보아야 한다.

function Character(info) {
    this.mainElem = document.createElement('div');
    this.mainElem.classList.add('character');
    this.mainElem.innerHTML = ''
        + '<div class="character-face-con character-head">'
            + '<div class="character-face character-head-face face-front"></div>'
            + '<div class="character-face character-head-face face-back"></div>'
        + ...(이하생략);

    document.querySelector('.stage').appendChild(this.mainElem);
    //마우스 클릭하는 곳으로 위치 설정한다.
    this.mainElem.style.left = info.xPos + '%';

    // 스크롤 중인지 아닌지 체크 가능!
    this.scrollState = false;
    // 바로 이전 스크롤 위치
    this.lastScrollTop = 0;
    this.xPos = info.xPos; 
    //객체 속성으로 위치 지정.
    this.speed = info.speed;
    this.direction;
    // 좌우 이동 중인지 아닌지
    this.runningState = false;
    this.rafId;
    this.init();
}

캐릭터 생성 함수에는 정말 많은 내용이 들어 있다.
먼저 div태그에 해당하는 캐릭터 div 요소들을 추가해 주어야 하므로, classList.add('character') 를 통하여 캐릭터라는 클래스를 가진 div태그를 추가해준다. 결국 stage에 추가해주어야 하므로 appendChild 메소드를 이용하여 mainElem을 추가해준다. 이 때, 왼쪽으로부터 얼마나 떨어져 있을지를 결정해주는 info.xPos를 결정해준다.

여기서 왜 자꾸 this라는 것을 붙일까? 라는 궁금증이 생길 수 있다.

this가 의미하는 것은 해당 객체에 속하는 속성임을 나타내기 위해서 계속하여 작성하는 것으로, this를 콘솔에 찍으면 Character이 나온다. 캐릭터에 대한 속성으로 받아들이면 된다.

그리고 이후에는 마우스가 스크롤 중인지 아닌지, 이전 스크롤 위치를 저장하는 것, 객체의 스피드를 지정하는 것, 방향 결정, 뛰고 있는 상태인지 아닌지를 결정하는 프로퍼티들이 존재하며 마지막의 init() 을 통해 메소드를 프로토타입으로 지정해둔다.

메소드를 프로토타입으로 지정한 내용은 아래와 같다.

Character.prototype = {
    constructor: Character,
    init: function () {
        const self = this;
        //안에서 this쓰면 window 전역객체 가리키기 때문에 캐릭터 객체를 self에 넣어서 사용하는 것이다.
    }
};

이 프로토타입 함수에서 이후 진행할 스크롤 상태와 캐릭터가 달리는 형태를 결정하는 작업을 진행할 것이다.
이 때, 미리 self 라는 변수를 만들고 this 객체를 넣어 두었는데,
프로토타입 메소드 안에서 this 라는 것을 사용할 때 window 전역객체를 가리켜 undefined가 뜨는 현상을 방지하기 위해서 캐릭터 객체인 thisself에 미리 넣어둔다.

4. 스크롤할수록 채워지는 스크롤바

캡쳐한 화면에서 스크롤바를 담은 컨테이너가 얇은 편이라 잘 보이지는 않지만 스크롤을 할 때 파란색 게이지가 채워지고, 스크롤을 다시 위로 올리면 파란색 게이지바가 줄어드는 것을 볼 수 있다.

이것을 구현하기 위해서 가장 먼저 해두어야 하는 작업이 있다.

브라우저 크기가 변할 때마다 그에 대한 스크롤 가능 범위가 변한다

이 사실을 알고 있으면 브라우저 크기 변화에 대응하는 스크롤 변화를 컨트롤 할 수 있다.
그렇기 때문에 브라우저 resize 이벤트가 발생하게 되면 스크롤 범위를 다시 세팅할 수 있도록 함수를 작성해주어야 한다.

function resizeHandler(){
        maxScrollValue = document.body.offsetHeight - 
    window.innerHeight
    }

document.body.offsetHeight 는 body 요소의 전체 사이즈를 의미한다. 여기서 window.innerHeight 브라우저의 현재 높이를 빼게 되면 스크롤을 할 수 있는 범위가 도출된다.
이와 같은 계산 방법은 스크롤 이벤트를 이용한 다양한 곳에서 활용될 수 있으므로 기억하고 있으면 매우 좋다.

window.addEventListener('resize', resizeHandler);
resizeHandler();

이렇게 정리하면 보고 있는 브라우저에서 resize 이벤트가 발생하였을 때 resizeHandler 이벤트가 발생하여서 스크롤 가능 범위를 다시 정해준다.
이제 스크롤 범위를 잘 정해 주었으니 그에 따라서 색이 변화하는 컨테이너를 만들기만 하면 된다.

window.addEventListener('scroll', function(){
   const scrollPer = pageYOffset / maxScrollValue
   const zMove = scrollPer * 980 - 490;
        //0부터 1000까지 비율로 설정! 
   houseElem.style.transform = 'translateZ(' + zMove + 'vw)';
        //progressbar  처리
   barElem.style.width = scrollPer * 100 + '%';
})

pageYOffset/maxScrollValue를 통하여 0부터 1까지의 비율로 설정할 수 있다. 왜냐하면 pageYOffset은 현재 스크롤한 범위를 나타내기 때문이다. 그 값에 100을 곱하면 퍼센트로 결정되므로, barElem.style.width를 이용하여 색을 칠할 수 있다.여기서 houseElem.style.transform은 스크롤을 하면서 Z방향으로 축을 이동하면서 방을 이동하는 효과를 의미한다.
처음에 Z인덱스를 -490만큼 이동시켜 놓았으니까 비율에서 490을 제거하는 것이다.

5. 스크롤하면 달려가는 캐릭터

Character.prototype = {
    constructor: Character,
    init: function () {
        const self = this;
        window.addEventListener('scroll', function () {
            clearTimeout(self.scrollState);

            if (!self.scrollState) {
                self.mainElem.classList.add('running');
            }

            self.scrollState = setTimeout(function () {
                self.scrollState = false;
                self.mainElem.classList.remove('running');
            }, 500);
        });
    }

스크롤 이벤트가 발생할 때, running 상태의 클래스를 덧붙여주는 것으로 달려가는 모습을 구현할 수 있다.
이 때 시간과 관련하여 setTime 함수를 사용한 것을 볼수 있는데 이것은 왜 사용한 것일까?

스크롤 이벤트가 발생할 때마다 running 클래스를 add하게 되면 효율적인 코드가 되지 않는다.

물론 setTimeout 함수를 사용하지 않아도 된다. 그러나 스크롤을 할 때마다 정말 많이 running 클래스를 실행한다면 실행 시간이 번거로운 코드가 될 것이다. 이를 방지하기 위해서, scrollState 라는 변수를 설정해두고 디폴트값으로 false라고 둔다. 이 때 setTimeout 함수에서 반환하는 ID를 가지게 되어서 scrollState 함수에 양수의 수가 들어가게 된다. scrollState는 True 상태가 된다.
만약 다시 스크롤을 진행할 경우, clearTimeout 함수가 다시 스크롤 시 수행되어서 0이 리턴, scrollState 함수는 다시 false 상태가 된다. 만약 스크롤을 하고, 0.5s동안 아무 일도 일어나지 않으면 콜백함수를 수행하는데, 다시 스크롤 상태를 false 로 만들어 주고, running 클래스를 지워주는 것이다. 이런 방식으로 running 클래스가 무분별하게 추가되고 사라지는 것을 방지할 수 있다.

그럼 running 클래스에는 무엇이 있을까?

@keyframes ani-running-leg {
    from {
        transform: rotateX(-30deg);
    }
    to {
        transform: rotateX(30deg);
    }
}
@keyframes ani-running-arm {
    from {
        transform: rotateY(30deg);
    }
    to {
        transform: rotateY(-30deg);
    }
}
.character.running .character-leg-right { animation: ani-running-leg 0.2s alternate infinite linear; }
.character.running .character-leg-left { animation: ani-running-leg 0.2s alternate-reverse infinite linear; }
.character.running .character-arm { animation: ani-running-arm 0.2s alternate infinite linear; }

running 클래스가 붙으면 다리와 팔이 움직이는 애니메이션을 등록해놓았다.
이제 스크롤을 아래로 하면, 캐릭터가 뒷모습을 보이면서 걸어가고, 스크롤을 위로 하면 캐릭터가 앞모습을 보이면서 걸어가는 것으로 구현해야 한다.
스크롤을 위로 올리는지 아래롤 올리는지 알기 위해서는 스크롤 변화값을 계산하면 된다.

 if (self.lastScrollTop > pageYOffset) {
     self.mainElem.setAttribute('data-direction', 'backward');
} else {
     self.mainElem.setAttribute('data-direction', 'forward');
}
self.lastScrollTop = pageYOffset;

스크롤 위치를 lastScrollTop이라는 변수에 저장을 해 둔다. 이전 스크롤 위치가 더욱 크다면, 스크롤을 올리는 상태이므로 뒷모습을 보이도록 하고, 현재 스크롤 위치가 더욱 크다면 스크롤을 내리는 상태이므로 앞모습을 보이게 한다.

6. 방향키에 따라 움직이는 캐릭터

좌우 방향키를 누르면 움직이고, 그 자리에 캐릭터가 잘 존재할 수 있도록 코드를 만들어보자.

 window.addEventListener('keydown', function (e) {
    if (self.runningState) return;
	if (e.keyCode == 37) {
        self.direction = 'left';
        self.mainElem.setAttribute('data-direction', 'left');
        self.mainElem.classList.add('running');
        self.run(self);
 	    self.runningState = true;
    } else if (e.keyCode == 39) {
		self.direction = 'right';
        self.mainElem.setAttribute('data-direction', 'right');
        self.mainElem.classList.add('running');
        self.run(self);
        self.runningState = true;
   }
});

키코드 37은 왼쪽, 키코드 39는 오른쪽을 가리킨다.
키보드를 눌렀을 때 run 함수를 실행시켜주는데, run 메소드가 어떤 일을 하는지 보자.

run: function (self) {
  if (self.direction == 'left') {
    self.xPos -= self.speed;
  } else if (self.direction == 'right') {
    self.xPos += self.speed;
  }
  if (self.xPos < 2) {
    self.xPos = 2;
  }

  if (self.xPos > 88) {
    self.xPos = 88;
  }

  self.mainElem.style.left = self.xPos + '%';
  self.rafId = requestAnimationFrame(function () {
    self.run(self);
  });
}

xPos를 speed만큼 빼거나 더해주는 방식으로 위치를 구현한다. 단, 일정 범위 이상을 나가면 안되므로 범위 제한을 걸어둔다. 걸어다니는 애니메이션은 keydown이 아무리 빨리 되어도 부드럽게 프레임 넘기듯이 수행되지 않기 때문에 requestAnimationFrame을 사용하여 부드럽게 프레임 넘기든 애니메이션이 실행되도록 한다.
이제 키를 떼면 캐릭터가 멈추어야 한다.

   window.addEventListener('keyup', function (e) {
      self.mainElem.classList.remove('running');
      cancelAnimationFrame(self.rafId);
      self.runningState = false;
});

키 눌렀던 것을 떼었을 때, running 클래스를 지우고, 애니메이션 프레임 진행되던 것을 취소하기 위해서 cancleAnimationFrame메소드를 실행한다.

이렇게 CSS와 JS만으로 풍부하고 재미있는 3D 효과를 구현해낼 수 있다. 기본적인 키보드 누르기, 클릭 및 스크롤 이벤트를 배웠으니 여러가지 방면에서 활용해볼 수 있을 것으로 기대된다.

0개의 댓글