ROLLIT은 사용자가 직접 롤러코스터를 설계하고,
1인칭 또는 3인칭 시점으로 자유롭게 주행을 체험할 수 있는 롤러코스터 시뮬레이션 웹 애플리케이션입니다.
내가 만든 놀이기구를 직접 타보는 상상을 현실로 구현합니다.
- 기획 의도
- 기술 스택
- 팀원 & 역할
- 주요 기능
- 기능 구현 방식
- 이슈
- 개발 일정
- 회고
“내가 만든 롤러코스터를 직접 타볼 수 있다면?”
어릴 적 한 번쯤 꿈꿔봤던 상상,
내가 설계한 롤러코스터를 실제로 타볼 수 있다면 어떨까?
그 물음에서 ROLLIT은 시작됐습니다.
복잡한 3D 도구나 전문 지식 없이도,
누구나 쉽게 레일을 설계하고, 직접 주행을 체험할 수 있는 환경을 만들고 싶었습니다.
설계의 즐거움과 주행의 짜릿함
두 가지를 모두 담은 인터랙티브한 시뮬레이션을 구현하고자 이 프로젝트를 기획하게 되었습니다.
전역 상태 관리 라이브러리는 애플리케이션 전반에서 공유되는 상태를 효율적으로 관리하기 위해 사용됩니다.
저희 프로젝트에서는 레일 정보, 오디오, 시점 모드 등 다양한 상태가 페이지와 기능을 넘나들며 사용되었기 때문에,
이들을 안정적이고 일관되게 관리할 수 있는 도구가 필요했습니다.Zustand는 다음과 같은 이유로 적합하다고 판단했습니다:
- 필요한 상태만 선택적으로 구독 가능해 렌더링 성능 최적화
- store 외부에서도 직접 접근 가능하여
useFrame
등에서도 활용 용이- 기능 단위로 모듈화하기 쉬워 유지보수에 유리
팀원 | GitHub | 역할 |
---|---|---|
박소영 | @qwp0 | 3D 씬 초기 구성, 카메라 이동, 레일 배치, 소품 프리뷰 및 배치, 주행 시뮬레이션, 추천 코스 불러오기 |
김수영 | @Rei-SWE | 패널 UI, 레일 충돌 처리, 레일 되돌리기/초기화, 사운드 효과 |
클릭만으로 간편하게 나만의 롤러코스터를 만들 수 있습니다.
원하는 레일을 선택하고 클릭하면 자동으로 방향을 맞춰 연결되며,
되돌리기와 초기화 기능을 통해 초보자도 부담 없이 설계할 수 있도록 구성했습니다.
자동 레일 연결 | 클릭 시 방향과 위치를 계산해 레일 자동 연결 |
추천 코스 | 설계 없이도 즉시 주행 가능한 코스 제공 |
레일 연결 유효성 검사 | 주행 시작 전, 끊긴 구간이 있을 경우 경고 표시 |
충돌 감지 | 바닥/소품과 충돌 시 배치 차단 및 경고 표시 |
되돌리기/초기화 | 설계를 이전으로 되돌리거나 초기화 가능 |
설계한 레일을 따라 카트가 주행하며, 다양한 시점으로 감상할 수 있습니다.
곡선을 따라 주행하는 카트에 경사도 기반 속도 변화를 적용하여,
실제 롤러코스터와 유사한 주행 경험을 제공합니다.
주행 경로 생성 | 배치된 레일을 기반으로 곡선을 생성해 카트 주행 경로 구성 |
속도 변화 적용 | 레일의 경사도를 기반으로 가속/감속 적용 |
시점 전환 | 1인칭 / 3인칭 시점으로 주행 장면을 자유롭게 감상 |
주행 속도 설정 | 전체 시뮬레이션 배속 조절 가능 |
리플레이 지원 | ‘다시 타기’ 버튼을 통해 반복 감상 가능 |
설계와 주행의 생동감을 극대화하는 기능을 통해 더욱 몰입도 있는 경험을 할 수 있습니다.
테마파크 연출을 위한 소품 배치,
사운드 및 카메라 조작 기능을 통해 편리하고 몰입도 높은 환경을 제공합니다.
소품 배치 | 다양한 GLB 모델을 배치하여 테마파크 분위기 연출 |
카메라 조작 | 마우스와 키보드로 자유롭게 시점을 이동 및 회전 |
사운드 제어 | 배경음(BGM) 및 효과음(SFX) 자동 재생 및 볼륨 조절 지원 |
사용자가 클릭한 레일이 자동으로 이전 레일 끝에 이어붙도록 만들고 싶었습니다.
이를 위해서는 (x, y, z)
위치와 곡선 방향에 따라 회전을 함께 고려해야 합니다.
먼저 레일 종류별로 상대 좌표 기준의 포인트 배열을 정리한 템플릿을 만들었습니다.
각 레일의 시작점은 [0, 0, 0]
, 끝점은 레일 종류에 따라 다르게 설정했습니다.
- 직선 레일:
[0, 0, 0]
->[0, 0, -6]
- 곡선 레일: 곡률을 반영해 더 많은 포인트를 정밀하게 배치했습니다.
또한 레일의 방향 전환을 위해, 각 레일의 기본 Y축 회전값을 담은 정보 객체도 함께 정의했습니다.
- 왼쪽 커브:
Math.PI / 2
- 오른쪽 커브:
-Math.PI / 2
이렇게 준비한 정보들을 바탕으로, 새로운 레일은 아래와 같은 구조로 구성됩니다.
{
id: 'uuid', // 클릭 시 생성된 고유 식별자
modelPath: 'glb/rail/curveLeft.glb', // GLB 모델 경로
position: [x, y, z], // 앞 레일의 끝점 기준 배치 위치
rotation: [0, yRotation, 0], // 누적 회전값을 반영한 Y축 회전
points: [...], // 절대 좌표계로 변환된 포인트 배열
endPoint: [x, y, z], // 현재 레일의 마지막 위치
accumulatedYRotation: number // 방향 누적값 (곡선일수록 증가)
}
레일의 정보를 활용해, 클릭한 레일을 앞선 레일에 자동으로 이어붙이는 로직을 구성했습니다.
핵심은 이전 레일의 끝점과 누적 회전값을 기준으로 새로운 레일의 위치와 방향을 계산하는 것입니다.
- 이전 레일 정보 조회
- 마지막에 배치된 레일의 끝 좌표와 누적 회전값을 가져옵니다.
- 선택된 레일 정보 확인
- 선택된 레일의 미리 정의된 상대 좌표와 기본 회전값을 가져옵니다.
- 새 레일의 회전값 계산
- 이전 레일의 회전에 새 레일의 회전값을 더해 전체 트랙의 방향 흐름이 이어지도록 방향을 결정합니다.
-> 예를 들어 왼쪽 커브 뒤에 또 왼쪽 커브를 붙이면, 회전은+90도 +90도 = +180도
가 됩니다.
- 절대 좌표 변환
- 상대 좌표 기반 포인트들을, 앞 레일의 끝 지점과 방향에 맞춰 실제 3D 공간상의 좌표로 변환합니다.
-> 내부적으로는getWorldRailPoints
라는 유틸을 사용해 회전과 위치를 모두 반영한 절대 좌표 포인트 리스트를 생성합니다.
- 새 레일 정보 추가
- 계산된 위치와 방향, GLB 모델 경로, 이어진 포인트 등을 새 레일 데이터로 만들어 전체 레일 목록에 추가합니다.
이 구조를 통해 사용자는 단순히 레일을 클릭하는 것만으로도 자연스럽게 이어지는 구조를 경험할 수 있습니다. 또한, 변환된 포인트는 이후 카트 주행 경로의 중심선으로도 재사용됩니다.
초기에는 같은 레일을 두 번 클릭해도 useEffect
가 반응하지 않아, 두 번째 배치가 되지 않는 문제가 있었습니다.
이는 레일 객체의 참조가 동일하기 때문에 발생한 문제였습니다.
해결 방법: 레일 객체에 uuid
를 추가하여
매 클릭마다 새로운 객체로 만들어주어, useEffect
가 정상 동작하도록 처리했습니다.
초기에는 물리 엔진을 활용해 카트를 움직이도록 설계했습니다.
@react-three/rapier
를 이용해 카트와 레일에 콜라이더를 적용하고, impulse, 중력, 마찰력 등을 조절해 카트를 앞으로 밀어주는 방식이었습니다.
- 곡선이나 경사 구간에서 카트가 자주 튕기거나 탈선
- 레일과 카트 콜라이더의 미세한 간극, 구조적 불일치로 인해 현실적인 주행 구현의 어려움
결론: 리얼한 물리 시뮬레이션보다, 항상 동일한 결과를 보장하는 부드러운 주행 경험이 더 중요하다고 판단했습니다.
카트가 레일 중심선을 따라 정확하게 움직이는 경로 기반 애니메이션 방식으로 전환했습니다.
보이지 않는 곡선 경로(가상의 길)를 만들고, 카트가 그 경로를 따라 정확하게 움직이도록 구조를 설계했습니다.
이 경로는 사용자가 배치한 레일들의 중심선 좌표를 기반으로 만들어집니다.
-
레일 중심선 포인트 연결
각각의 배치된 레일은 내부적으로 중앙을 따라가는 좌표 정보를 가지고 있습니다.
이 좌표들은 절대 좌표계 기반의 중심선 포인트이며, 사용자가 배치한 순서에 따라 자연스럽게 이어지도록 연결합니다.- 모든 레일의 중심선 좌표를 순서대로 이어붙여 하나의 전체 경로 포인트 배열을 만듭니다.
- 이때 레일 간 중복되는 좌표는 제거해 곡선이 꺾이지 않고 자연스럽게 이어지도록 정제합니다.
-
곡선 객체 생성
단순한 점의 나열만으로는 실제 곡선을 계산하거나 그 위의 위치·방향을 정밀하게 구할 수 없습니다.
따라서 이 포인트 배열을 기반으로,Three.js
의CatmullRomCurve3
객체를 생성합니다.CatmullRomCurve3
는 적은 수의 포인트만으로도 부드러운 곡선을 자동으로 보간해 줍니다.- 이 곡선 객체를 통해 원하는 지점의 위치(
getPointAt
), 방향(getTangentAt
)을 수학적으로 계산할 수 있으며,
실제 주행 애니메이션에도 활용됩니다.
-
전역 상태로 관리
생성된 곡선은 설계 화면과 시뮬레이션 화면 양쪽에서 참조되므로
Zustand
를 통해 전역 상태로 관리합니다.
- 카트는 곡선 경로에서 현재 주행 진행도를 기준으로 자신의 위치와 방향을 계산해 이동합니다.
- 주행 진행도는
0
에서 시작해1
에 도달하면 종료됩니다. - 매 프레임마다
getPointAt(progress)
로 위치를 계산하고,getTangentAt(progress)
로 이동 방향 벡터를 구해 카트의 회전 방향을 조정합니다. - 이때 방향 벡터를 기준으로 회전 행렬을 생성한 뒤, 쿼터니언으로 변환해 카트에 적용하며, 정밀한 방향 보정이 가능합니다.
- 카트가 레일 위에 너무 붙어 보이지 않도록, 살짝 띄우는 offset을 적용해 시각적으로 자연스러운 느낌을 더했습니다.
- 현재는 고정된 속도 값(
speed = 0.2
)으로 진행도를 증가시키지만, 시뮬레이션 시작 전에 전체 속도를 설정할 수 있습니다.
사용자가 BGM(배경음악)과 SFX(효과음)를 동시에 재생하는 상황에서, 두 사운드가 끊기거나 서로 겹치지 않도록 제어하는 방법이 필요했습니다. 이를 위해 각각의 사운드를 독립적이고, 영향 받지 않게 설계하는 것이 관건이었습니다.
먼저, 각각의 사운드를 별개로 관리할 수 있도록 전역 상태와 이벤트 트리거를 활용했습니다.
- BGM은 페이지에 진입하는 순간 자동으로 재생되고, 사용자 조작 없이 계속 재생되도록 설계했습니다.
- SFX는 특정 이벤트에 맞춰 즉각적이고, 별개로 재생됩니다.
이때, 중요한 것은 사운드의 상태를 별개로 유지하며, 일시 정지/음소거/볼륨 조절도 각각 독립적이어야 한다는 점입니다.
- 문제: 브라우저 정책상 자동 재생이 차단될 수 있기에, 사용자 클릭 이후에
playBgm()
을 호출하는 구조가 필요합니다. - 해결책: 사용자 행동(클릭)을 트리거로 사용하여, 배경음을 재생시키는 함수를 호출하고 이후 재생 상태를
useAudioStore
같은 전역 상태에서 유지합니다.
- 문제: 빠르게 반복 클릭하거나 여러 효과음을 동시에 재생하면, 서로 겹치거나 덮어쓰기 문제가 발생할 수 있습니다.
- 해결책:
playSfx()
함수에서 효과음을 재생하기 전에, 기존의 효과음을 강제로 정지시키거나, 새로 요청된 효과음만 재생하도록 설계합니다. 예를 들어, 성공 효과음과 실패 효과음을 별도로 관리하여, 필요할 때마다 각각 독립적으로 재생하는 방식입니다.
웹 브라우저에서 마우스는 항상 2D 좌표계 (clientX
, clientY
)를 기준으로 움직입니다.
하지만 Three.js
기반의 3D 씬에서는 카메라 중심의 3D 좌표계를 사용하므로, 단순히 마우스 좌표를 그대로 쓸 수 없습니다.
따라서 사용자가 보고 있는 화면 기준으로 마우스가 바라보는 3D 공간상의 위치를 계산해주는 로직이 필요합니다.
이를 위해 총 세 단계를 거쳐 구현했습니다.
Three.js
의 Raycaster
는 x: -1 ~ 1
, y: -1 ~ 1
의 범위를 가지는 정규화된 좌표를 입력으로 받습니다.
이는 WebGL
의 좌표계 기준입니다.
Raycaster
란?
광선이 어떤 대상과 충돌하는지를 계산하는 Three.js의 유틸 도구입니다.
WebGL
은 화면 전체를 1~1 범위의 정사각형 공간으로 바라봅니다.
아래의 변환을 통해 Raycaster
가 올바른 방향으로 광선을 쏠 수 있습니다.
const x = (event.clientX / window.innerWidth) * 2 - 1;
const y = -(event.clientY / window.innerHeight) * 2 + 1;
정규화된 좌표를 바탕으로, 카메라에서 마우스가 가리키는 방향으로 광선을 생성합니다.
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
이 과정은 카메라 기준으로 마우스 방향에 가상의 광선을 쏘는 것과 같으며,
이후 이 광선이 씬 내의 오브젝트나 평면과 어디서 교차하는지를 계산할 수 있게 됩니다.
아이템은 항상 바닥에 배치되기 때문에,
광선이 y=0 평면과 어디에서 교차하는지를 계산해야 합니다.
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // y=0 평면 정의
raycaster.ray.intersectPlane(plane, intersect);
이렇게 얻은 intersect
값이 곧 3D 프리뷰 오브젝트의 위치가 됩니다.
위치를 상태로 관리하고, 해당 위치에 프리뷰 오브젝트를 렌더링함으로써 마우스를 따라 부드럽게 움직이는 프리뷰 기능을 구현했습니다.
WASD
키 입력으로 카메라가 자유롭게 앞뒤좌우로 이동하는 기능을 구현하던 중,
직선 이동을 기대한 것과 달리 카메라가 특정 지점을 중심으로 원형 궤도를 그리며 움직이는 문제가 발생했습니다.
즉, 사용자는 앞으로 직진하는 이동을 기대했지만,
실제 동작은 마치 고정된 회전 중심을 기준으로 공전하는 것처럼 보이는 비직관적인 이동 방식이었습니다.
이상한 움직임의 원인을 파악하기 위해 몇 가지 요소를 나눠 점검했습니다.
우선, camera.position
은 WASD
키 입력에 따라 정상적으로 갱신되고 있었지만,
이동 후 마우스를 드래그해 회전시키면,
회전 중심이 이동 전 위치에 고정되어 있다는 사실을 발견했습니다.
이 문제의 핵심은 OrbitControls
의 작동 방식과 우리가 구현한 이동 방식의 불일치였습니다.
OrbitControls
는controls.target
을 기준으로 카메라가 공전합니다.- 하지만
WASD
이동 로직은camera.position
만 변경하며,
회전 기준인controls.target
은 그대로 두고 있었습니다.
결과적으로 카메라는 이동했지만 회전 중심은 제자리에 남아 있는 상태가 되어,
사용자가 WASD
로 움직이더라도 회전 중심을 기준으로 원을 그리며 움직이는 효과가 나타난 것입니다.
문제를 인식한 뒤, 두 가지 해결책을 고려했습니다.
- 카메라 이동 시
controls.target
도 함께 이동시켜 회전 중심 동기화 OrbitControls
를 제거하고, 직접 카메라 이동 및 회전 로직 구현
두 번째 방법은 더 많은 작업과 세밀한 제어가 필요하므로,
기존 기능을 최대한 활용하는 첫 번째 방식을 선택했습니다.
camera.position.add(moveVector); // 카메라 이동
controls.target.add(moveVector); // 회전 중심도 동일하게 이동
controls.update();
이제 WASD
로 직선 이동할 때 회전 중심도 함께 따라오므로
이동 시 공전 현상이 제거되고, 자연스러운 직선 이동이 가능해집니다.
결과적으로, OrbitControls
의 장점은 그대로 유지하면서
사용자의 직선 이동 기대와 일치하도록 회전 중심을 동기화해 해결할 수 있었습니다.
레일 설치 시, 기존 레일과 새로 설치할 레일 간의 충돌을 감지하는 과정에서 정확하지 않거나 잘못된 충돌 판정이 발생했습니다.
특히 곡선 레일, 회전된 레일 등의 특수 상황에서는 충돌을 정확히 감지하기 어려웠습니다.
처음에는 단순한 거리 계산을 통해 충돌을 감지하려 했습니다. 하지만 이 방식은 곡선 레일이나 회전된 레일에서 예상과 다른 충돌 감지를 초래했습니다.
예를 들어, 회전된 레일은 시각적으로 겹치지 않지만, 좌표상으로는 겹칠 수 있어 충돌로 감지되는 문제를 겪었습니다.
- 곡선 레일의 시작점과 끝점을 정의하는 데 어려움이 있었고, 회전된 레일은 좌표 계산상의 차이로 인해 겹치지 않지만 충돌로 판단되는 문제가 있었습니다.
- 거리 기반 충돌 감지는 이러한 특수 상황을 정확하게 반영할 수 없었고, 자기 자신과의 충돌을 감지하는 문제도 발생했습니다.
-
박스 기반 충돌 감지
- THREE.js의
Box3
객체를 사용하여 각 레일을 박스 형태로 감싸 충돌을 감지하는 방법을 도입했습니다. intersectsBox()
메서드를 사용하여 두 객체 간의 충돌을 체크할 수 있습니다.- 이를 통해 곡선 레일과 회전된 레일에 대해서도 충돌 감지의 정확도를 높일 수 있었습니다.
const boxA = new THREE.Box3().setFromObject(mesh); const boxB = new THREE.Box3().setFromObject(otherMesh); const isIntersecting = boxA.intersectsBox(boxB);
- THREE.js의
-
자기 자신과의 충돌을 제외
- 가장 최근에 설치한 레일이 충돌 검사에 포함되지 않도록 자기 자신을 제외하는 로직을 추가했습니다.
const railsToCheck = placedRails.slice(0, -1); // 가장 마지막 레일 제외
-
곡선 레일의 충돌 해결
- 부동소수점 오차와 회전 각도의 미세한 차이로 인한 불필요한 충돌 감지 문제를 해결하기 위해 회전 각도의 허용 오차를 도입했습니다.
- 이 방법을 통해 시각적으로 겹쳐 보이지 않더라도, 충돌로 간주되지 않도록 설정했습니다.
const angleDiff = Math.abs(railA.rotation - railB.rotation); if (angleDiff < 0.3) { // 허용 오차 내에서는 충돌이 아니라고 간주 }
-
좌우 대칭 커브 레일 충돌 해결
Box3
충돌 감지 방식이 회전 각도를 고려하지 않기 때문에, 좌우 대칭 커브 레일에서 발생한 충돌 문제를 해결하기 위해 회전 각도와 연결 방향을 추가로 고려했습니다.
프로젝트 기간: 2025. 05. 19 - 2025. 06. 20
1주차
아이디어 구체화 및 협업 환경 구성
- 아이디어 브레인스토밍 및 선정
- 협업 규칙 수립 및 칸반 구성
2주차
환경 세팅 및 UI 설계
- 프로젝트 초기 세팅
- 기본 UI 구현
- R3F 기반 3D 환경 구축
3주차
배치 로직 구현
- 3D 오브젝트 프리뷰 구현
- 소품 및 레일 배치 기능 구현
4주차
주행 로직 구현 및 배치 로직 고도화
- 곡선 경로 기반 주행 로직 구현
- 효과음 및 배속 조절 기능 구현
- 레일 되돌리기 및 초기화 기능 구현
- 레일 연결 여부 판단 로직 구현
5주차
최종 기능 구현 및 서비스 배포
- 추천 코스 불러오기 기능 구현
- 시점 전환 및 재시작 기능 구현
- 도메인 구매 및 배포
어려웠던 점
서로 작업 상황을 명확히 공유하지 못해 협업의 흐름이 끊기는 어려움이 있었습니다.
이번 프로젝트는 소규모 인원으로 밀접하게 협업하는 구조였기 때문에,
서로 늘 함께 있다는 이유로 진행 상황 공유가 자연스럽게 생략되는 경우가 종종 있었습니다.
그로 인해 세부적인 방향 차이가 누적되며 예상치 못한 수정 작업으로 이어지는 경험도 있었습니다.
이후로는 ‘말하지 않아도 알겠지’보다는 ‘작은 것도 함께 정리하고 나누는 시간’의 중요성을 실감하게 되었습니다.
성장한 점
초반의 어려움을 계기로, 필요한 논의가 있을 때 먼저 질문을 던지고
선택지를 정리해 제안하는 방식으로 팀의 의견을 이끌어내고자 했습니다.
또한 회의록 꾸준히 정리하며 논의 내용을 구조화하기 위해 노력했습니다.
그 과정에서 ‘내가 맡은 파트’에만 집중하기보다,
‘팀 전체의 흐름과 방향성’을 함께 고민하는 시야가 생겼습니다.
앞으로도 협업에서 중요한 것은 완성된 결과물뿐만 아니라,
서로가 편하게 이야기 나눌 수 있는 과정과 태도라는 점을 잊지 않고 실천해 나가고 싶습니다.
어려웠던 점
프로젝트 초기에는 각자 칸반을 나누고, 브랜치를 만들어 작업을 시작했습니다. 병합이 이루어지면서 병합 충돌이 발생하는 일이 잦았고, Git 사용법에 미숙함으로 충돌을 해결하는 과정에서 실수를 반복했습니다.
Git을 처음 다뤄보던 때라 rebase와 머지 전략에 대한 이해가 부족했고,
병합할 때마다 발생하는 문제를 해결하기 위해 팀원의 도움을 받는 일이 많았습니다.
그 당시 팀원들도 각자의 업무가 있음에도 불구하고 저로 인해 팀원들이 점점 더 부담을 느낀 것 같아 미안한 마음도 들었습니다.
그러던 중, 팀 내에서 협업 절차를 효율적으로 개선할 필요성을 느끼고 git 사용법을 노션에 정리해주었습니다.
이를 통해 Git 사용과 PR 병합에 대한 절차가 명확해졌고, 실수가 줄어들었습니다.
메뉴얼 덕분에 더 이상 매번 팀원에게 물어보지 않고 자주 발생하는 실수를 피할 수 있었습니다.
성장한 점
이번 프로젝트에서 직접 의견을 표출하고 문제를 적극적으로 제기하는 중요성을 깨달았습니다.
처음에는 의사결정 과정에서 자신의 의견을 내는 것에 대한 부담이 있었지만, 점차 팀 내에서 자신의 생각을 명확히 전달하고 논의하는 것이 얼마나 중요한지 경험하게 되었습니다.
특히, 코드 리뷰 과정에서 팀원들의 피드백을 통해 내가 생각지 못한 부분을 발견하고 코드를 이해하는 과정에서 많이 성장했습니다.
팀원들이 작성한 코드를 이해하고, 그들의 생각을 반영하여 개선점을 찾는 과정에서 문제 해결 능력과 소통 능력이 향상되었습니다.
또한, 서로의 의견을 존중하며 협업을 이어가다 보니, 팀워크가 더욱 강화되었고, 결과적으로 프로젝트의 질도 높아졌습니다.