2026-07-12

React Three Fiber로 끝없이 휘는 터널 만들기

Rabbit Hole의 굴은 아래로 끝없이 내려가면서 계속 좌우로 휩니다.
그렇게 긴 메시를 진짜로 만들 수는 없으니, 화면에 보이는 건 토끼가 떨어지는 동안 재활용되는 벽 세그먼트 여섯 개예요.
이 터널이 어떻게 굴러가는지, 그리고 끝에 가서 한참을 헤맨 버그 하나를 적어봅니다.

휘어지며 끝없이 이어지는 토끼굴 — 벽이 곡선을 따라 굽어 있다

게임은 터널이 휜다는 걸 모른다

나중에 제일 고생을 덜어준 결정은, 곡선을 게임 로직 바깥에 둔 거예요.

충돌, 점수, 스폰은 전부 터널 좌표에서 돌아갑니다.
얼마나 내려왔는지(깊이), 벽을 도는 각도, 그리고 높이.
이 좌표계에서 터널은 그냥 곧은 원통이에요.
휘어지는 건 렌더러가 그 위에 얹어서 하는 일이라, 경로가 아무리 구불거려도 물리 쪽은 알 필요가 없습니다.

그래서 경로는 작은 모듈 하나로 끝나요.
깊이를 넣으면 두 가지를 알려줍니다.
여기 터널 중심이 어디인지, 그리고 어느 방향을 보고 있는지.

컨트롤 포인트 대신 사인파

휘는 경로를 만드는 흔한 방법은 컨트롤 포인트를 여러 개 찍고 그 사이로 Catmull-Rom 스플라인을 지나가게 하는 거예요.
저는 포인트를 저장하거나 실행 중에 곡선을 샘플링하고 싶지 않았어요.
어느 깊이든 물어보면 중심을 바로 돌려받고 싶었고, 그래서 중심선을 사인파의 합으로 뒀습니다.

const X = (d) =>
  a1 * Math.sin((d / w1) * TAU + p1) +
  a2 * Math.sin((d / w2) * TAU + p2);
const Y = (d) => a3 * Math.sin((d / w3) * TAU + p3);

진폭과 파장, 위상은 시드 난수(mulberry32)에서 뽑아요.
그래서 같은 시드는 언제나 같은 터널을 만들죠.
데일리 런이 공정한 이유가 이거예요.
그날은 모두가 똑같은 지형을 받으니까요.

벽 메시는 경로를 따라 방향을 잡아야 하니 진행 방향(탄젠트)도 필요한데, 중심이 그냥 함수라서 미분만 하면 됩니다.
유한차분 같은 걸 쓸 일이 없어요.

const dX = (d) =>
  a1 * (TAU / w1) * Math.cos((d / w1) * TAU + p1) +
  a2 * (TAU / w2) * Math.cos((d / w2) * TAU + p2);

frame(d)는 위치 (X, Y, -d)와, 탄젠트 (dX, dY, -1) 방향으로 lookAt을 건 쿼터니언을 돌려줍니다.
다시 만들어도 똑같이 넣을 부분이 하나 있어요.
시작 구간에서 smoothstep으로 곡선을 0부터 서서히 키웠습니다.
그래서 스폰 바로 아래에선 터널이 완전히 곧고, 토끼가 움직이기 시작해야 휘기 시작해요.
출발점부터 이미 기울어 있으면 그냥 고장 난 것처럼 보이거든요.

세그먼트 여섯 개를 돌려 쓴다

터널은 <Segment> 컴포넌트 여섯 개가 그립니다.
각자 SEGMENT_LENGTH 깊이의 벽 한 조각을 맡고, 토끼가 내려가면 세그먼트를 없앴다 다시 만드는 게 아니라 더 아래 조각을 가리키도록 돌려놓아요.

조각을 배정하는 뻔한 방법은 base + index인데, 문제는 새 조각으로 넘어가는 순간 여섯 개의 목표가 한꺼번에 밀린다는 거예요.
그러면 같은 프레임에 여섯 개가 전부 지오메트리를 다시 만들면서 화면이 뚝 끊깁니다.
링버퍼 인덱스로 바꾸면 한 번에 하나만 다시 만들어요.

export function segmentWorldIndex(base, index, count) {
  return base + ((((index - base) % count) + count) % count);
}

base = Math.floor(depth / LEN) - 1로 두면 각 세그먼트는 [base, base + count - 1] 범위의 월드 인덱스에 묶여 있고, 실제로 움직이는 건 방금 토끼 뒤로 빠진 하나뿐이에요.
그게 맨 앞으로 점프하죠.
내내 메시는 여섯 개, 경계를 넘을 때 값싼 재빌드 한 번.

세그먼트 6개 링버퍼 — 토끼 뒤로 밀려난 하나만 앞으로 재배치되고 나머지 다섯은 그대로다

TubeGeometry로 벽을 휜다

벽 하나하나가 TubeGeometry예요.
Three.js는 곡선만 주면 그걸 따라 튜브를 훑어주니, 경로를 두 깊이 사이에서 샘플링하는 작은 THREE.Curve 서브클래스로 감쌌습니다.

class SectionCurve extends THREE.Curve {
  constructor(path, d0, d1) { super(); this.path = path; this.d0 = d0; this.d1 = d1; }
  getPoint(t, target = new THREE.Vector3()) {
    const d = this.d0 + (this.d1 - this.d0) * t;
    const c = this.path.center(d);
    return target.set(c.x, c.y, -d);
  }
}

재빌드는 세그먼트의 월드 인덱스가 실제로 바뀔 때만 돌아요.
새 지오메트리로 갈아끼우고 이전 걸 버립니다.

if (worldIndex.current !== wi) {
  worldIndex.current = wi;
  wall.current.geometry.dispose();
  wall.current.geometry = new THREE.TubeGeometry(
    new SectionCurve(path, wi * LEN, (wi + 1) * LEN),
    TUBULAR_SEGMENTS, RADIUS, RADIAL_SEGMENTS, false,
  );
}

dispose()는 빼먹지 마세요.
안 하면 경계를 넘을 때마다 TubeGeometry를 하나씩 바닥에 흘리게 되고, 그게 세션 내내 GPU 메모리를 조용히 갉아먹습니다.

첫 판에만 나던 버그

이제 성가셨던 부분.
worldIndex.current !== wi 가드는 일단 움직이기 시작하면 맞는 코드고, 그래서 며칠을 속았어요.

깊이 0에선 월드 인덱스가 한동안 안 움직입니다.
새로고침 직후엔 실제 런이 시작되기 전에 임시 시드로 터널이 만들어져요.
런이 진짜로 시작되면 경로 객체가 진짜 데일리 경로로 갈리는데, 정수 월드 인덱스는 그대로라 가드가 아예 안 걸립니다.
그래서 처음 150미터쯤이 임시 시드의 지오메트리로 계속 그려져요.
재시도를 누르면 깊이가 리셋되면서 전부 다시 만들어지니 두 번째 판은 멀쩡했고, 새로고침하면 바로 재발했죠.
첫 판만 깨지고 그 뒤로는 괜찮다.
초기화 순서 버그의 전형적인 신호예요.

해결은 정수뿐 아니라 경로 객체 자체도 같이 보는 거였어요.

if (worldIndex.current !== wi || pathLast.current !== path) {
  worldIndex.current = wi;
  pathLast.current = path;
  // 재빌드
}

경로는 런당 한 번만 갈리니 평상시엔 아무 일도 안 해요.
진짜 런의 첫 프레임이 진짜 경로로 다시 그려지는 것만 보장할 뿐입니다.

정리하면

결국 화면에 떠 있는 건 벽 세그먼트 여섯 개예요.
곡선은 어느 깊이를 넣든 곧바로 좌표를 내주고, 세그먼트가 새로 만들어지는 건 경계를 넘는 순간 딱 하나뿐이며, 세그먼트를 다시 배정하는 동안 프레임마다 새로 생성하는 객체는 없습니다.
그래서 터널은 끝없이 휘면서도, 같은 시드를 받은 사람끼리는 그날 똑같은 길을 달리게 돼요.

돌아보면 결국 맨 처음의 결정 하나로 다 풀린 셈이에요.
충돌이나 점수 같은 게임 로직은 곧은 원통 좌표에 그대로 두고, 휘어지는 건 화면에 그릴 때만 처리한 것.
다음에 또 엔드리스 러너를 만든다면 저는 이 구조부터 잡을 것 같습니다.