2주차 · 수업 노트

표지 텍스처 + 무한 스크롤

이번 주에는 책에 표지·책등·뒷면 이미지(텍스처)를 입혀 15권을 한 줄로 세우고, 마우스 휠을 굴리면 책이 좌우로 끝없이 흐르게 만듭니다. 시작은 숙제 발표, 그리고 1주차 복습 — 렌더러까지 통으로 다시 짓고 출발해요.

발표

내가 가져온 책 표지 5권

한 사람씩 7분 이내로 —

1) 어떤 콘셉트를 정했는지 (좋아했던 책 / 선물받은 책 / 앞으로 읽을 책)
2) 어떤 책 5권을 가져왔는지, 왜 그 책인지
3) 표지는 어떻게 구했는지 (스캔 · 사진 · 인터넷)

지난주 숙제로 모아 온 표지들을 서로 소개하고 시작합니다. 오늘 이 표지들이 진짜 3D 책이 돼요.

준비 · 파일 이름

표지 파일 이름을 코드처럼 바꿔 주세요

스크린샷 2026-07-08 오후 7.34.22.png   →   cover-1.png
IMG_2043.JPG                           →   cover-2.png
책표지 최종(진짜최종).png                 →   cover-3.png
사진 2026-07-11.jpeg                    →   cover-4.png
다운로드 (3).png                         →   cover-5.png

코드는 파일 이름으로 이미지를 부릅니다. 띄어쓰기·괄호가 섞인 원래 이름은 코드에 그대로 타이핑하기 어렵고 실수하기 쉬워요. 영문 소문자 + 번호 규칙(cover-1 ~ cover-5)으로 바꿔 두면, 뒤에서 반복문 하나로 다섯 권을 전부 부를 수 있습니다.

상수 표지 = 로더.불러오기('cover-1.png')            // 한 장씩 부를 때
상수 표지 = 로더.불러오기('cover-' + 번호 + '.png')  // 반복문으로 부를 때

스튜디오의 [내 이미지] 버튼으로 올리면 목록에 로더.불러오기('cover-1.png') 코드가 그대로 떠요 — [복사]를 눌러 붙여 넣으면 됩니다. 같은 이름을 또 올리면 새 파일로 덮어써요. 확장자까지 .png로 통일해도 괜찮습니다 — 브라우저는 파일 내용으로 그림을 읽어서 문제없어요.

첫 번째 · 장면·카메라·렌더러

통으로 다시 짓기 복습

상수 장면 = 새 삼차원.장면()
장면.배경 = 새 삼차원.색('#1a1a1a')

상수 카메라 = 새 삼차원.원근카메라(75, 창.너비 / 창.높이, 0.1, 1000)
카메라.위치.설정(0, 0, 12)
카메라.바라보기(0, 0, 0)

상수 렌더러 = 새 삼차원.렌더러({ 안티앨리어싱: 참 })
렌더러.크기설정(창.너비, 창.높이)
문서.본문.붙이기(렌더러.돔요소)

렌더러.애니메이션시작(() => {
  렌더러.렌더링(장면, 카메라)
})

1주차에서 배운 것들을 이번엔 하나하나 설명하지 않고 통으로 넣습니다 — 장면·카메라·렌더러, 그리고 매 프레임 그리는 애니메이션 뼈대까지. (오늘 뒤에서 이 뼈대 안을 채워 나갈 거예요.) 카메라는 정면(z=12)에 두었어요.

안티앨리어싱: 참 — 도형 가장자리의 계단 현상을 부드럽게 해 줍니다.

Tip

왜 14와 22일까요? 이따 카메라가 반지름 12짜리 원으로 서재 둘레를 돌 거예요. 정면의 가까운 책은 카메라에서 약 12 거리 — 시작(14)보다 가까워서 또렷합니다. 그리고 책이 반대쪽에서 다시 나오는 끝 지점은 카메라에서 약 22 거리 — 끝(22)에 딱 걸려서 완전히 잠긴 뒤에 순간이동해요. 그래서 무한 스크롤 이음새가 안 보이는 거예요.

두 번째 · 조명

표지가 잘 보이게 빛을 켭니다 복습

상수 주변빛 = 새 삼차원.주변빛('흰색', 0.6)
장면.추가(주변빛)

상수 방향빛 = 새 삼차원.방향빛('흰색', 0.8)
방향빛.위치.설정(5, 5, 5)
장면.추가(방향빛)

주변빛(전체) + 방향빛(오른쪽 위에서). 색·세기·방향을 바꾸면 분위기가 달라집니다.

세 번째 · 크기변경

창 크기 대응 복습

창.이벤트걸기('크기변경', () => {
  카메라.화면비율 = 창.너비 / 창.높이
  카메라.투영갱신()
  렌더러.크기설정(창.너비, 창.높이)
})

1주차 마지막에 배운 그대로 — 습관처럼 코드 맨 아래에 붙입니다. 여기까지가 복습이에요.

네 번째 · 텍스처로더

텍스처 — 이미지를 불러오는 도구

상수 로더 = 새 삼차원.텍스처로더()
상수 종이 = 새 삼차원.표준재질({ 색: '아이보리' })

텍스처로더는 이미지를 불러오는 도구 — three.js 안의 이미지 읽기 라이브러리를 부르는 거라고 생각하면 됩니다. 종이색 재질은 책의 옆·위·아래 면에 같이 쓸 거라 하나만 만들어 둡니다.

'아이보리'는 1주차의 '하늘색'처럼 한국어 색 이름으로 그대로 쓸 수 있어요.

Tip

상수라는 개념은 사실 단축키 같은 거예요. 좀 있다가 이렇게 쓸 텐데 —

상수 표지 = 로더.불러오기('경로')

사실 이건 이렇게 적어도 됩니다.

상수 표지 = 새 삼차원.텍스처로더().불러오기('경로')

로더를 따로 떼어 이름을 붙여 두면 글의 양이 줄어드는 거죠. 지난주에 배운 상수들도 전부 이렇게 작동해요.

다섯 번째 · 준비 숫자들

책수 · 간격 · 전체폭

상수 책수 = 15
상수 간격 = 2.5
상수 전체폭 = 책수 * 간격

상수 그룹 = 장면.그룹만들기()   // 그룹을 만들어 장면에 바로 붙인다

전체폭은 책 15권이 차지하는 전체 길이입니다. 뒤에서 "끝까지 가면 처음으로 돌아와"를 계산할 때 기준이 되는 숫자라서, 미리 이름을 붙여 둡니다.

그룹은 책을 담는 상자예요. 낱권을 장면에 흩어 두는 대신 상자 하나에 모아 담습니다 — 담아 두면 그룹.자식들(상자 안 목록)로 언제든 다시 꺼낼 수 있어요.

여섯 번째 · 반복문

책 15권 — 이 노동을 반복문이 대신합니다

// 반복문이 없다면 — 책 15권을 전부 손으로 만들어야 합니다:
상수 책1 = 새 삼차원.도형( … 'books/cover-1.png' … )   // 자리 0
상수 책2 = 새 삼차원.도형( … 'books/cover-2.png' … )   // 자리 2.5
상수 책3 = 새 삼차원.도형( … 'books/cover-3.png' … )   // 자리 5
// … 이걸 열다섯 번 …

되긴 되지만, 책 크기를 바꾸려면 열다섯 군데를 고쳐야 해요. 반복문을 쓰면 이 노동을 안 해도 됩니다.

반복 (변수 i = 0; i < 책수; i++) {
  상수 번호 = i + 1
  상수 표지 = 로더.불러오기('books/cover-' + 번호 + '.png')
  표지.색공간 = 삼차원.SRGB색공간
  상수 책등 = 로더.불러오기('books/spine-' + 번호 + '.png')
  책등.색공간 = 삼차원.SRGB색공간
  상수 뒷면 = 로더.불러오기('books/backcover-' + 번호 + '.png')
  뒷면.색공간 = 삼차원.SRGB색공간

  상수 책 = 새 삼차원.도형(
    새 삼차원.상자모양(2.1, 2.9, 0.2),   // 폭 높이 두께
    [
      종이,                                // 오른쪽 +x
      새 삼차원.표준재질({ 텍스처: 책등 }),  // 왼쪽 -x — 책등
      종이,                                // 위 +y
      종이,                                // 아래 -y
      새 삼차원.표준재질({ 텍스처: 표지 }),  // 앞 +z — 표지
      새 삼차원.표준재질({ 텍스처: 뒷면 }),  // 뒤 -z — 뒷면
    ]
  )
  책.위치.설정(i * 간격, 0, 0)   // x, y, z
  그룹.추가(책)   // 장면이 아니라 그룹에 — 상자에 담아 기억시킨다
}

i는 0인데, i가 책수보다 작은 상태에서는 i가 증가 — 이 반복문이 계속 반복되는 거예요. 번호가 1씩 증가할 때마다 그 파일명(cover-1, cover-2, …)을 차례로 불러오기 시작하는 거죠. 상자모양의 여섯 면 순서는 [오른쪽, 왼쪽, 위, 아래, 앞, 뒤] — 두 번째 칸(왼쪽)에 책등, 다섯 번째(앞)에 표지, 여섯 번째(뒤)에 뒷면을 입힙니다.

색공간 = SRGB색공간 — 사진이기에 필수로 넣습니다. three.js가 사진의 색을 렌더링할 때는 이렇게 따로 지정해야 실제 색으로 보여요.

Tip

책을 만들었으면 무대에 올려야겠죠. 그런데 장면.추가(책)이 아니라 그룹.추가(책)입니다 — 굳이 상자(그룹)에 담는 이유가 있어요.

반복문은 한 바퀴 지날 때마다 안에서 만든 책(이름)을 잊어버려요. 그룹에 담아 두면 그룹.자식들 — 상자 안에 든 책 목록 — 로 언제든 다시 꺼낼 수 있습니다.

그리고 카메라는 x = 0을 보고 있기 때문에, 처음엔 화면에 들어오는 앞쪽 책 몇 권만 보여요. 나머지 책도 그룹 안에는 있지만 화면 오른쪽 밖에 있는 거죠. 그래서 애니메이션에서 그룹.자식들을 한 권씩 꺼내, 다 같이 움직이며 보는 방법을 찾아볼 거예요. (3주차에는 이 상자를 통째로 돌리는 데도 씁니다.)

일곱 번째 · 상수와 변수

이제 처음으로 "변수"를 씁니다

변수 스크롤거리 = 0       // 스크롤할 때마다 계속 쌓일 값

const(상수)는 다른 값으로 바꿀 수 없는 이름, let(변수)은 언제든 다른 값으로 바꿀 수 있는 이름. 무한 스크롤에 쓸 스크롤거리는 휠을 굴릴 때마다 계속 바뀔 값이라 변수로 만듭니다.

여태 상수만 쓴 이유 — 장면·카메라·렌더러는 고유명사였기 때문이에요. var라는 것도 있는데 오래된 문법이라 쓰지 않습니다.

여덟 번째 · 휠 입력

휠을 굴린 만큼 스크롤거리를 옮깁니다

창.이벤트걸기('휠', (e) => {
  스크롤거리 = 스크롤거리 + (e.세로굴린양 + e.가로굴린양) * 0.01
})

e는 "방금 일어난 휠(스크롤) 사건의 정보"를 담은 상자입니다. 세로굴린양(deltaY)은 세로 휠, 가로굴린양(deltaX)은 트랙패드 가로 쓸기 — 둘을 더해 두면 마우스 휠로도, 트랙패드 옆으로 쓸기로도 책이 흐릅니다.

e.세로굴린양이 100 움직이면 그거의 0.01은 1 — 스크롤거리는 1이 되는 것. 저 0.01이 1에 가까워질수록 속도는 빨라지겠죠?

Tip

e를 읽는 방법 — "휠 이벤트가 발생하면, 그 이벤트 정보를 e라는 이름으로 받을게."
즉, 휠 이벤트가 발생했을 때 만들어진 이벤트 정보 = e.

아홉 번째 · 애니메이션

책의 x 자리를 계산합니다

렌더러.애니메이션시작(() => {
  그룹.자식들.각각((책, i) => {
    변수 x = (i * 간격 - 스크롤거리) % 전체폭
    …
  })
})

복습 때 만든 애니메이션 뼈대 안을 이제 채웁니다. 그룹.자식들은 상자 안에 든 책 목록 — 거기에 붙는 .각각은 "한 권씩 꺼내서 이 일을 해라"입니다. 꺼낸 한 권을 책이라 부르고, i는 그 책이 몇 번째인지(0부터) 알려줘요.

각각(forEach)은 목록 전용 반복 — 반복문과 하는 일은 같지만, 이미 담아 둔 목록에는 "몇 번 돌지" 셀 필요 없이 각각이 더 읽기 쉬워요.

x를 계산하는 한 줄은, 사실 "책의 x좌표를 계산한다"는 뜻입니다. 세 조각으로 나눠 보죠.

숫자 하나로 끝까지 따라가 봅시다.
(계산이 쉽게 책 5권 · 간격 2 → 전체폭 = 10 인 작은 책장, 주인공은 3번째 책)

① i * 간격     원래 책이 있어야 하는 자리
               3번째 책 → 3 × 2 = 6

② - 스크롤거리       스크롤한 만큼 움직인다
               휠을 위로 굴려서 스크롤거리가 -6이 되면
               → 6 - (-6) = 6 + 6 = 12   (음수를 빼는 건 더하는 것과 같아요)
               → 새 자리는 12. 원래 자리(6)에서 12까지 갔으니
                 움직인 거리는 12 - 6 = 6 … 오른쪽으로 6만큼

③ % 전체폭     "끝까지 가면 처음으로 다시 돌아와"
               12는 전체폭(10)을 넘었다!
               → 12 % 10 = 2 … 한 바퀴를 돌아 자리 2에서 다시

왜 더하기가 아니라 빼기(− 스크롤거리)일까? — 스크롤거리는 "내가 책장을 따라 걸어간 거리"이기 때문이에요.

Tip

버스를 타고 앞으로 달리면, 창밖 가로수는 뒤로 지나가 보이죠. 스크롤거리가 바로 "내가 책장을 따라 오른쪽으로 걸어간 거리"예요. 내가 오른쪽으로 3만큼 걸으면, 책들은 화면에서 왼쪽으로 3만큼 밀려 보입니다 — 그래서 책의 자리 = 원래 자리 − 스크롤거리.

플러스로 쓰면 틀리는 게 아니라 방향만 반대가 돼요. "휠을 아래로 굴리면 앞으로(책들이 왼쪽으로) 나아간다"는 느낌을 맞추려고 빼기를 쓰는 겁니다.

① 원래 자리 — 3번째 책은 3 × 2 = 6
0 6 10
전체폭
② 위로 굴려 스크롤거리 = -6 → 6 - (-6) = 12 — 오른쪽으로 6만큼 이동
6 10 12 — 넘었다!
↓ 12 % 10 = 2 — 전체폭(10)을 넘었으니 한 바퀴(10)를 빼고 처음 쪽으로
③ % 전체폭 → 자리 2에서 다시
2

휠을 굴리거나 끌어 보세요 — 책장은 사실 빙글 도는 벨트입니다 (가만두면 저절로 흐릅니다)

파란 책(3번째)의 자리 = (6 − 0.0) % 10 = 6.0

아래 원이 진짜 모습이에요 — 책들은 회전초밥 벨트처럼 원 위를 빙글 돕니다. 위의 직선은 그 원을 빨간 지점에서 가위로 잘라 펼친 것. 그래서 직선의 오른쪽 끝(10)과 왼쪽 끝(0)은 사실 같은 지점이고, 책이 끝을 "넘어가는" 순간 반대편에서 나오는 것처럼 보여요. 그 계산이 % 전체폭입니다.

같은 책(3번째)을 세 단계 내내 따라간 거예요: 원래 자리 6 → 스크롤로 12까지 밀림 → 한 바퀴(10)를 넘었으니 2로 돌아옴. 아래로 굴리면 스크롤거리가 +가 되어 책이 왼쪽으로, 위로 굴리면 -가 되어 오른쪽으로 흘러갑니다.

그래서 이 한 줄을 말로 읽으면 — "책의 원래 위치를 계산하고, 스크롤한 만큼 움직인 다음, 끝까지 가면 처음으로 다시 돌아오게 해." 이것만 기억하면 됩니다.

열 번째 · 끝에서 다시

왼쪽 끝을 넘으면, 오른쪽에서 다시

상수 왼쪽끝 = -전체폭 / 2
상수 오른쪽끝 = 전체폭 / 2

책들은 화면 가운데(0)를 기준으로 서 있으니, 전체폭의 절반씩이 양쪽 끝입니다. 여기서 처음 필요해지니 이제 이름을 붙입니다.

전체폭이 10이라면 —
-5
왼쪽끝
0
가운데
5
오른쪽끝
만약 (x < 왼쪽끝) {
  x = x + 전체폭      // 왼쪽 끝을 넘어가면 → 오른쪽 끝에서 다시 나온다
}
만약 (x > 오른쪽끝) {
  x = x - 전체폭      // 오른쪽 끝을 넘어가면 → 왼쪽 끝에서 다시 나온다
}

책.위치.x = x

책.위치.x = x 로 하면 매 프레임 실시간으로 위치가 잡히고, 회전은 만들 때 뽑아 둔 자기만의 속도가 매 프레임 쌓여서 제각각 돌아갑니다. (매 프레임 새로 뽑으면 전부 비슷하게 떨리기만 해요 — 한 번 뽑아 저장하는 게 비법.)

핵심 — x는 책이 아니에요. 책이 어디 있는지 알려주는 숫자예요. 책.위치.x = x 는 "x라는 숫자에 맞춰서 책을 그곳에 놓아라"라는 뜻입니다.

Tip

코드를 보지 말고 x 하나만 생각해 봅시다. x는 책의 현재 가로 위치 — 책 한 권을 위에서 내려다본 그림이에요.

x = 0 — 책이 가운데 있다
-5 0 5
x = 3 — 책이 오른쪽에 있다
x = -4 — 책이 왼쪽에 있다
x = -6 — 왼쪽 끝(-5)을 넘어갔다!
↓ 만약 (x < 왼쪽끝) → x = -6 + 10 = 4
x = 4 — 오른쪽 끝에서 다시 나온다
x = 6 — 반대로 오른쪽 끝을 넘어가면
↓ 만약 (x > 오른쪽끝) → x = 6 - 10 = -4
x = -4 — 왼쪽 끝에서 다시 나온다

이렇게 끝을 넘어간 책을 반대쪽으로 되돌려 놓기 때문에, 책들이 끝없이 흐르는 것처럼 보이는 거예요.

%와 만약은 하는 일이 달라요 — %는 몇 바퀴를 돌았든 한 바퀴 안으로 접는 일(시계), 만약 두 줄은 그 값을 화면 안으로 옮기는 일(문지기)입니다. 카메라가 한가운데 0을 보고 있어서 화면이 -전체폭/2 ~ +전체폭/2이거든요 — %만 쓰면 책이 전부 카메라 오른쪽에 쏠려요.

x는 계산 중인 메모라 두 번 바뀌어도 됩니다 — 초안(%) → 퇴고(만약) → 제출(책.위치.x = x). 책이 실제로 움직이는 건 마지막 줄이에요.

팩맨이 화면 왼쪽으로 나가면 오른쪽에서 나오죠 — 바로 그 규칙입니다. 순간이동은 화면 가장자리 바깥(±18.75)에서 일어나서 눈에 잘 띄지 않아요.

휠을 굴려 보세요 — 15번째 다음 칸에는 1번 책이 다시 옵니다 (진짜 책장 위에 순서만 띄운 것)

휠을 굴리면 계산이 여기 보입니다

위 숫자는 "몇 번째 칸인지", 괄호 안은 "실제로 몇 번 책인지"예요. 앞으로 굴리면 16번째, 17번째 … 숫자는 계속 커지지만, 책은 15권이 계속 돌아 나옵니다 — 16번째 칸에 1번 책이 다시 오는 순간이 무한 스크롤의 정체입니다.