오시레이더에 버튜버를 3D 은하로 보여 주는 화면을 만들었습니다. 소속사는 항성, 소속 멤버는 기수마다 궤도를 도는 행성, 개인 버튜버는 나선팔에 놓인 별입니다. 별마다 그 사람의 프로필 사진이 붙습니다.
사람이 5천 명을 넘으니, 사진 붙은 원판을 사람 수만큼 그리는 방식으로는 버티기 어렵습니다. 성능을 가른 것은 두 가지였습니다.
- 그리기 횟수: 사람이 몇 명이든 GPU 에 한 번에 그리게 했습니다.
InstancedMesh와 텍스처 아틀라스 한 장을 씁니다. - 사진을 언제 받나: 화면에서 얼굴이 보일 만큼 큰 사람만 받습니다. 첫 화면 사진이 360장 · 약 4MB 에서 3장 · 22KB 로 줄었습니다.

아래는 three.js 의 기본 개념을 짚으면서, 각 문제를 어떻게 풀었는지 적은 기록입니다.
three.js 에서 성능을 가르는 것: 그리기 호출
three.js 화면은 장면(Scene), 카메라, 렌더러로 이루어집니다. 장면에 물체(Mesh = 모양 + 재질)를 넣으면 렌더러가 매 프레임 그립니다.
이때 물체 하나마다 GPU 에 "이걸 그려라"는 명령, 즉 그리기 호출(draw call) 이 한 번씩 나갑니다. 물체 100개는 괜찮지만 수천 개가 되면 GPU 보다 이 명령을 보내는 CPU 쪽이 먼저 막힙니다. 그래서 같은 모양을 많이 그릴 때는 그리기 호출을 줄이는 게 첫 번째 일입니다.
| 방법 | 그리기 호출 | 사진 |
|---|---|---|
사람마다 Mesh + 텍스처 | 사람 수만큼 | 사람마다 텍스처 1장 |
InstancedMesh + 아틀라스 | 1번 | 큰 캔버스 1장을 칸으로 나눠 씀 |
지금 화면의 그리기 호출 수는 renderer.info.render.calls 로 볼 수 있습니다. 개발 중에 이 숫자를 찍어 두면, 물체를 더했을 때 호출이 같이 느는지 바로 보입니다.
사람은 InstancedMesh 하나로
InstancedMesh 는 같은 모양 · 같은 재질을 자리와 크기만 바꿔 여러 번 그리는 물체입니다. 각 사람의 자리와 크기는 행렬 하나(setMatrixAt)로 넣습니다.
const mesh = new THREE.InstancedMesh(quad, material, capacity)
mesh.count = planets.length
// 매 프레임: 궤도를 따라 움직인 자리를 넣는다
planets.forEach((p, i) => {
center.set(...planetPosition(p, stars, time))
scale.setScalar(p.radius)
mesh.setMatrixAt(i, matrix.compose(center, rotation, scale))
})
mesh.instanceMatrix.needsUpdate = true
capacity 는 처음부터 넉넉히 잡습니다. 뒤에 적을 "다가가면 사람으로 바뀌는" 별까지 들어가게 미리 자리를 만들어 두고, count 만 늘립니다. InstancedMesh 는 만들 때 정한 개수만큼 버퍼를 잡아서, 더 늘리려면 버퍼를 새로 만들어야 합니다. count 를 줄이면 앞에서부터 그만큼만 그립니다.
사람마다 다른 값(사진 칸 번호, 테두리 색)은 InstancedBufferAttribute 로 붙입니다. 셰이더는 이 값을 인스턴스마다 다르게 읽습니다.
const cells = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 2).fill(-1), 2)
const rings = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3)
quad.setAttribute('aCell', cells) // 아틀라스에서 이 사람 사진의 칸 (없으면 -1)
quad.setAttribute('aRing', rings) // 테두리 색 = 7일 팔로워 증감률
늘 카메라를 보는 원판은 셰이더에서
사진 원판은 어느 방향에서 봐도 정면이어야 합니다(빌보드). 원판마다 매 프레임 회전을 계산하는 대신, 정점 셰이더에서 화면 기준으로 펼칩니다.
vec4 center = modelViewMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
float radius = length(instanceMatrix[0].xyz); // 행렬에 넣은 크기
center.xy += position.xy * radius; // 화면 기준으로 펼친다
center.z += radius; // 구의 앞면 깊이로 — 행성 중심을 지나는 궤도 선이 사진을 덮지 않게
gl_Position = projectionMatrix * center;
center.z += radius 를 넣은 이유는 궤도 선입니다. 원판을 행성 중심 깊이에 두면, 행성 중심을 지나는 궤도 선이 얼굴 위로 그어집니다.
이렇게 자리를 셰이더가 옮기면 three.js 가 물체의 경계를 잘못 계산해, 화면 안에 있는데도 잘라 낼 수 있습니다. 은하 전체가 늘 화면 근처라서 mesh.frustumCulled = false 로 잘라 내기를 껐습니다.
사진은 아틀라스 한 장에
그리기를 한 번으로 하려면 재질도 하나, 텍스처도 한 장이어야 합니다. 그래서 2048×2048 캔버스 한 장을 96px 칸으로 나눠, 사람마다 칸 하나에 사진을 그립니다. 이걸 텍스처 아틀라스라고 합니다.
| 항목 | 값 |
|---|---|
| 캔버스 | 2048 × 2048 |
| 칸 | 96px, 21 × 21 = 441칸 |
| GPU 메모리 | 약 16MB (밉맵 포함 전) |
441칸으로 5천 명을 다 담을 수는 없습니다. 그래서 칸을 빌려 줍니다.
- 칸은 화면에서 얼굴이 보일 만큼 커진 사람에게만 줍니다. 멀리서 점으로 보이는 사람은 사진을 받지 않습니다.
- 빈 칸이 없으면 가장 오래 안 보인 사람의 칸을 가져옵니다. 지금 보이는 사람의 칸은 뺏지 않습니다.
- 칸이 없는 사람은 셰이더가 어두운 속에 테두리 색만 그립니다. 다가가면 얼굴이 채워집니다.
col = vCell.x < 0.0
? mix(vec3(0.06, 0.05, 0.1), vRing, 0.3) // 칸 없음: 어두운 속
: texture2D(uAtlas, uv).rgb; // 칸 있음: 아틀라스의 그 칸
사진이 늦게 도착하는 사이 칸을 다른 사람에게 넘겼을 수도 있습니다. 그래서 그리기 직전에 "이 칸이 아직 이 사람 것인지"를 한 번 더 봅니다. 확인하지 않으면 다른 사람 칸 위에 엉뚱한 얼굴이 그려집니다.
텍스처 업로드는 묶어서
캔버스에 사진을 그리면 GPU 로 다시 올려야 합니다(texture.needsUpdate = true). 이 업로드는 16MB 와 밉맵 전체라 무겁습니다. 다가갈 때 사진이 수십 장 몰려오는데, 한 장마다 올리면 화면이 버벅였습니다.
그래서 업로드를 0.3초에 한 번으로 묶었습니다.
const schedule = () => {
timer ??= setTimeout(() => {
timer = null
texture.needsUpdate = true
}, 300)
}
보이는 만큼만 사진을 받는다
어떤 사람이 화면에서 몇 픽셀로 보이는지는 거리로 계산합니다.
const px = (p.radius / center.distanceTo(camera.position)) * focalPx
if (px < 7) return // 점으로만 보인다 — 받지 않는다
atlas.load(i, px >= 22 ? 120 : 60, scan) // 작게 보이면 60px, 크게 보이면 120px
| 화면 반지름 | 받는 사진 |
|---|---|
| 7px 미만 | 받지 않음 |
| 7px 이상 | 60px (약 8KB) |
| 22px 이상 | 120px (약 24KB) |
사진은 60px 에서 120px 로 올라가기만 하고, 내려가지는 않습니다. 이 계산은 매 프레임 하지 않고 10프레임마다 합니다.
처음 방식은 은하를 열자마자 보이는 사람의 사진 360장, 약 4MB 를 받았습니다. 보일 만큼 큰 사람만 받게 바꾸니 첫 화면은 3장 · 22KB 가 됐습니다. 전체 보기에서는 대부분 점으로 보이기 때문입니다.
5천 명 중 300명만 사람으로
사진뿐 아니라 사람 자체도 처음부터 다 만들지 않습니다.
- 처음에는 소속 멤버와 개인 상위 300명만 사진 원판입니다.
- 나머지 5천여 명은 한 명당 빛 알갱이 하나입니다.
THREE.Points하나로 그리니 이것도 그리기 한 번입니다. - 카메라가 나선팔의 한 구간에 다가가면, 그 구간의 300명 정보를 받아 알갱이를 사진 원판으로 바꿉니다. 알갱이 자리는 순위로 정해져 있어서, 바뀐 사람도 같은 자리에 나타납니다.
알갱이를 지울 때는 색을 검정으로 바꿉니다. 덧셈 섞기(AdditiveBlending)로 그리고 있어서 검정은 보이지 않습니다. 배열에서 빼고 다시 만드는 것보다 훨씬 쌉니다.
화면 밖에서는 그리지 않는다
3D 화면은 매 프레임 그리니, 보고 있지 않을 때도 배터리와 CPU 를 씁니다. 페이지를 아래로 스크롤해 은하가 안 보이면 그리기를 멈춥니다.
const io = new IntersectionObserver(([entry]) => {
cancelAnimationFrame(raf)
if (entry?.isIntersecting) {
clock.getDelta() // 멈춘 동안의 시간을 버린다
tick()
}
})
io.observe(container)
멈췄다 돌아왔을 때 멈춘 시간만큼 행성이 한꺼번에 튀지 않게, 그 시간을 버립니다. 한 프레임에 흐르는 시간도 최대 0.1초로 묶었습니다.
고해상도 화면에서는 setPixelRatio(Math.min(window.devicePixelRatio, 1.75)) 로 픽셀 배율에 상한을 뒀습니다. 배율 3인 화면을 그대로 그리면 픽셀 수가 9배입니다.
three.js 는 그 페이지에서만 받는다
three.js 는 작지 않습니다. 은하는 지도 화면의 한 칸일 뿐이라, 다른 페이지 방문자까지 받게 할 이유가 없습니다. 그래서 장면 코드를 동적 import 로 불러, 은하 화면을 열 때만 받게 했습니다.
useEffect(() => {
let cancelled = false
import('./galaxy-scene').then(({ createGalaxyScene }) => {
if (!cancelled) scene.current = createGalaxyScene(el, galaxy, events)
})
return () => {
cancelled = true
scene.current?.dispose()
}
}, [box, galaxy])
빌드 결과로 three.js(gzip 146KB)와 은하 화면 코드(16KB)가 다른 페이지 청크에 섞이지 않은 것을 확인했습니다. 화면을 떠날 때는 만든 모양 · 재질 · 텍스처를 모두 dispose() 합니다. three.js 는 GPU 메모리를 자동으로 비우지 않기 때문입니다.
결과
| 항목 | 결과 |
|---|---|
| 첫 화면 사진 | 360장 · 약 4MB → 3장 · 22KB |
| 사람 그리기 | 몇 명이든 1번 (InstancedMesh) |
| 사진 텍스처 | 아틀라스 1장, 441칸 돌려쓰기 |
| 처음 만드는 사진 원판 | 상위 300명, 나머지 5천여 명은 빛 알갱이 |
| 화면 밖 | 그리기 멈춤 |
| three.js | 은하 화면에서만 (gzip 146KB) |
PC 에서 행성 2,400개 · 사진 124장이 떠 있는 상태로 120fps, 가장 느린 프레임 10ms 였습니다. 폰 실기기에서는 아직 재지 못했습니다. 아이폰 시뮬레이터에서 화면이 동작하는 것까지만 확인했습니다.
정리
수천 개를 3D 로 그릴 때 먼저 볼 것은 셋입니다.
- 그리기 호출 수: 같은 모양은
InstancedMesh로, 텍스처는 아틀라스 한 장으로 모으면 사람 수와 상관없이 한 번입니다. - 받을 양: 화면에서 몇 픽셀로 보이는지 계산해, 보일 만큼 큰 것만 받습니다. 멀리 있는 것은 점 하나로 충분합니다.
- 안 보일 때: 화면 밖이면 그리기를 멈추고, 화면을 떠나면 GPU 자원을 직접 비웁니다.
오시레이더를 만들며 겪은 다른 이야기는 오시레이더 만들기 연재에 모아 두었습니다. 은하 화면은 오시레이더 은하에서 볼 수 있습니다.