🔑 핵심 요약
✦ AI·GEN이 글은 저자가 개인 사이트 koimsurai.com의 그 3D 별밭 배경을 두고 거쳐온 세 단계 성능 대장정을 기록한다. 배경은 처음엔 제각각 노는 three.js 장식 특효 한 무더기——블랙홀, 파티클, 별밭, 토성——에 불과했고, 요소가 아직 적을 때는 무겁지 않았으나, 점점 겹겹이 쌓이고 실제로 측정에 부친 뒤에야 비로소 최적화의 길에 오른다. 먼저 별밭을 OffscreenCanvas Worker로 옮겨 렌더링을 메인 스레드 밖으로 내보내고(데스크톱 TBT를 17초에서 30밀리초로 압축), 이어 친구의 백화면이 뜨는 기기에 떠밀려 배경의 견고함을 보강하며, 마지막으로 브라우저 최신의 WebGPU/TSL로 전체를 처음부터 단일 canvas로 재작성한다——GPU를 85–90%에서 25%로 깎고, 별을 26,000개로 늘려 전부 shader 안에서 숨 쉬게 한다. 글은 WebGPU가 대체 무엇인지도 함께 풀어 설명하며, 거듭 뒤집힌 하나의 판단을 적어둔다. 「성능 최적화」와 「품질 업그레이드」는 서로 다른 두 장부이며, 목적 함수를 바꾸면 결론도 뒤집힌다.
배경은 사이트 전체에서 가장 먼저 눈에 들어오면서도, 가장 신경 쓸 필요가 없어야 하는 요소입니다. 그저 장식일 뿐입니다——천천히 도는 별밭 하나, 오른쪽에 걸린 토성 하나. 그런데 바로 이 장식이, 순진한 작은 장난감에서 시작해 자칫 첫 화면 전체를 백화면으로 만들 뻔한 괴물로 자라났습니다. 앞뒤로 세 단계, 스무 몇 개의 commit이 들었습니다.
이 글에서는 koimsurai.com의 그 별밭이 거쳐온 세 번의 변신기를 온전히 기록하려 합니다. 은 다들 어느 정도는 들어봤겠지만, 는 아주 새로운 물건이라 대부분은 이게 대체 뭘 할 수 있는지 모릅니다. 그래서 이야기를 풀어가면서 틈틈이 곁들여 설명하겠습니다. 제가 판단을 틀린 지점까지 포함해, 과정 전체를 최대한 충실하게 담으려 합니다.
NOTE
세 단계, 한 문장으로 Stage 1 제각각 돌아가는 three.js 장식 특효 한 무더기(순진했고, 나중에 쌓이고 나서야 무거워짐) → Stage 2 OffscreenCanvas Worker(별밭을 메인 스레드 밖으로 옮김) → Stage 3 WebGPU/TSL 단일 canvas 재작성(GPU 85–90%를 곧장 25%로 깎음).
출발점: 순진한 우주 배경(Stage 1)#
솔직히 말하면, 저는 처음엔 무슨 「성능 프로젝트」를 할 생각이 전혀 없었습니다. 이 배경은 조금씩 쌓아 올린 것입니다. 먼저 로 만든 블랙홀 파티클(BlackHole3D) 하나, 이어서 떠다니는 파티클 한 겹(SpaceParticles), 우주 왕복선 하나, 「무중력 도서관」 하나, 별밭 테마 배경 한 세트, 그리고 마지막에야 고리와 위성이 달린 토성(Saturn3D)을 올렸습니다. 하나같이 「어 이거 좀 멋있어 보이는데」 하며 손 가는 대로 붙인 것들입니다.
그때 저는 나중에 쓰게 될 그 성능 기법들——worker, 적응형 해상도, 탭 가시성 제어——에 대해 거의 아무것도 몰랐습니다. 그리고 솔직히, 맨 처음엔 무겁지 않았습니다. 요소가 아직 적을 때는 GPU 사용률이 얼마 안 됐고, 제 기기에서는 아주 매끄럽게 돌아갔으며, 저는 성능 따위 애초에 신경 쓴 적이 없었습니다.
문제는 조금씩 쌓이면서, 조금씩 버벅였다는 것입니다. 특효가 점점 더 겹겹이 쌓이고, 저도 마침내 진지하게 측정에 나섰을 때에야 조짐이 드러났습니다. 그리고 제 인생 첫 「진지한 최적화」는 사실 한참 나중의 일이었고, 방법도 죄다 증상만 덮는 반창고였습니다.
- (
useInView)로 무거운 특효를LazyComponent로 감싸, 가시 영역에 들어와야 로드되도록 했고; - Page Visibility API를 붙여, 탭이 다른 데로 넘어가면 모든 애니메이션의
requestAnimationFrame루프를 완전히 멈췄으며; - 파티클 수를 한 차례 깎고, 안티에일리어싱을 끄고, 고성능 GPU 모드를 켰습니다.
두 번째 단계: 별밭을 OffscreenCanvas Worker로 옮기다#
저를 진짜로 궁지에 몰아넣은 건 한 번의 Lighthouse였습니다. 모바일 점수가 눈 뜨고 못 볼 정도였고, 는 터무니없이 높았습니다. pagespeed.web.dev에서 실기로 다시 측정해 보니 데스크톱 숫자는 더 심했습니다. TBT 17,470밀리초——메인 스레드가 three.js에 꼬박 17초 동안 얼어붙어 있었던 겁니다.
재미있는 건 FCP / LCP는 다 훌륭했다는(사실 콘텐츠 자체는 아주 빨리 그려졌습니다) 점입니다. 그런데 다 그리고 난 뒤 페이지 전체가 들러붙은 것처럼 됐습니다. 진단해 보니 주범은 초기화가 아니라 60fps로 계속 돌아가는 그 WebGL 렌더링 루프였습니다. Lighthouse의 4× CPU 스로틀 아래에서는 매 프레임이 50ms를 넘겼고, 17초짜리 측정은 그대로 17초의 TBT로 쌓였습니다. 결론은 단단했습니다——60fps와 별 1만 8천 개를 유지하면서 TBT를 0에 가깝게 누르려면, 유일한 길은 렌더링을 메인 스레드 밖으로 옮기는 것, 로 옮기는 것뿐이었습니다.
고장 난 자 하나#
여기서 잠깐 옆길로 새서 한 가지를 짚고 넘어가겠습니다. 이게 Stage 2 전체의 중심축이기 때문입니다. 제가 재는 데 쓴 그 자 자체가 고장 나 있었습니다.
제가 자동화 Lighthouse를 돌린 그 환경은 headless였고, 게다가 GPU가 연결되어 있지 않았습니다(제 기기에는 외장 그래픽이 있지만, 그 점수 측정 환경은 그걸 먹지 못했습니다). GPU가 없는 환경에서 WebGL을 돌리면 로 되돌아가 CPU로 억지로 계산하는데, three.js의 비용을 실기와는 전혀 딴판일 만큼 부풀리고, 게다가 돌릴 때마다 편차가 큽니다. 저는 같은 code의 TBT가 2860 / 2880 / 4320 / 5410ms 사이를 마구 튀는 걸 두 눈으로 봤습니다. 가장 어이없던 순간은, 을 껐는데 TBT가 오히려 더 높아진 때였습니다——이건 물리적으로 불가능한 일이고, 곧 이 구간에서 그 환경으로 잰 모든 WebGL 숫자가 노이즈였다는 걸 증명하는 셈이었습니다.
CAUTION
믿을 수 없는 자를 쓰지 말 것 GPU가 없는 headless 환경은 SwiftShader로 WebGL을 소프트웨어 렌더링하며, 실기보다 수십 배 느린 데다 그 비용을 TBT에 쏟아붓습니다. 그런 환경에서 「첫 화면 3D」 점수를 재는 건 아무 의미가 없습니다——진짜 숫자는 오직 GPU가 있는 실기에서, 진짜 PageSpeed를 돌려야만 나옵니다.
바로 이 고장 난 자 때문에, 별밭을 worker로 옮겼는데도 점수 숫자가 거의 꿈쩍하지 않았을 때, 저는 한동안 엉뚱한 방향으로 추론해서 병목이 그 별들이 아니라 토성의 bloom이라고 여겼고, 하마터면 worker 변경 전체를 revert할 뻔했습니다.
분리: 별밭은 worker로, 토성은 메인 스레드에#
진짜 해법은 장면을 단칼에 둘로 가르는 것이었습니다.
- 별밭(약 27,000개의 점: 두 겹의 별 + 파편 + 반짝이는 별)을 로 옮김. 이건 DOM 의존이 거의 없어서 옮기기에 아주 적합합니다.
- 토성은 메인 스레드의 다른 canvas에 남김. 토성은 텍스처를 로드하고, 스크롤 위치를 읽고, 커서와 상호작용하고, bloom 후처리를 돌려야 합니다——이것들은 전부 worker로 들어갈 수 없습니다. 하지만 토성은 단일 오브젝트라 비용이 별 2만 7천 개보다 훨씬 낮습니다.
당시 공식으로 이미 만들어진 다리가 하나 있었습니다. @react-three/offscreen인데, <Canvas worker={worker} fallback={<Scene/>}/> 한 줄이면 장면을 worker로 던져 넣을 수 있고, 구버전 Safari조차 자동으로 메인 스레드로 되돌아가 같은 장면을 돌립니다.
worker는 모듈 레벨 싱글턴으로 만들었습니다(앱 전체에서 딱 한 번만 띄워, 그 832K짜리 chunk를 중복으로 내려받지 않도록). 모바일 쪽은 더 시원하게, WebGL을 통째로 걷어내고 순수 CSS 별밭으로 교체했고, 겸사겸사 hero를 콤팩트한 레이아웃으로 바꿨습니다.
실기가 뺨을 때리다(좋은 쪽으로)#
그 고장 난 자로는 결론을 내릴 수 없으니, 오직 실기 PageSpeed에 기댈 수밖에 없었습니다. 결과는 압도적이었습니다.
데스크톱 TBT가 17,470ms에서 30ms로 떨어졌습니다. 별밭이 통째로 메인 스레드를 떠나면서, 메인 스레드는 거의 더 이상 붙들리지 않았습니다. 「병목이 bloom이다」라던 그 추론은 고장 난 자가 저를 속인 것이었습니다——실기에서는 그 별 1만 8천 개가 바로 병목 본체였습니다.
TIP
덤으로 주운 교훈: 시크릿 창으로 Lighthouse를 측정하라 나중에 한 번은 데스크톱이 53점밖에 안 나왔는데, Lighthouse가 스스로 「Chrome 확장 프로그램이 로딩 성능을 끌어내렸다」고 경고했습니다——7.5MB가 전부 확장 프로그램이 주입한 것이었고(AdBlock, Grammarly, wakatime…), 제 사이트 자신의 code는 아주 조금만 차지했습니다. 시크릿 창으로 다시 측정하니 데스크톱 96점, Core Web Vitals 다섯 항목이 전부 초록이었습니다. 점수가 널뛴다면, 때로는 당신의 사이트 탓이 아닙니다.
Stage 2는 보기 좋은 지점에서 마무리됐지만, 저는 마무리 노트에 한마디를 남겼고 그게 예언이 됐습니다. 그 @react-three/offscreen은 RC에다 사실상 업데이트가 끊긴 상태라, 언젠가 three 메이저 버전을 올리면 터질 수 있다. 의존성 리스크를 정말 뿌리 뽑으려면 언젠가는 순수 three로 worker를 직접 짜야 한다(대공사). 그 「대공사」는 두 달 뒤에 곧 찾아왔습니다.
세 번째 단계: WebGPU/TSL로 처음부터 재작성#
먼저 기초부터: WebGPU란 대체 무엇인가#
더 나아가기 전에, 이 주인공을 30초만 짚고 넘어가겠습니다. 앞서 말했듯 이 배경은 three.js가 그리는 것이고, three.js가 밑바닥에서 딛고 선 건 WebGL입니다——2011년에 등장한, OpenGL ES에 대응하는 낡은 API입니다. 어디서나 돌아가지만 아키텍처가 낡았고, GPU와 소통할 때 CPU가 무척 수다스럽습니다.
가 바로 그 후계자입니다. 브라우저를 현대 네이티브 그래픽 API에 직접 이어붙여, GPU를 더 세밀하게 제어하고 CPU 오버헤드는 더 낮으며, 게다가 까지 열어줍니다——GPU가 「뭔가를 그리는」 것만이 아니라 대량의 병렬 수학을 계산하는 데도 쓰일 수 있게 됩니다. 절대다수의 사이트에게 이건 닭 잡는 데 소 잡는 칼입니다. 제가 정말 궁금했던 건, 이게 화면을 가득 채우고 계속 움직이는 배경 하나에 대체 쓸모가 있느냐였습니다. 답은 제 생각보다 훨씬 미묘했습니다——먼저 스포일러하자면, 처음에 제가 이걸 원한 이유는 틀렸습니다.
여기에 딸려 오는 게 입니다. JS로 shader를 쓰고 두 backend로 자동 컴파일되는 노드식 언어로, 이번 재작성의 주된 도구입니다.
도화선: 내게는 없는 그래픽 카드 한 장#
어느 날 친구가 스크린샷 한 장을 보내왔습니다. 자기 기기로 koimsurai.com을 열었더니 첫 화면 전체가 터지고 백화면이 됐다는 겁니다. 에러는 THREE.WebGLRenderer: Error creating WebGL context였습니다. 그 친구 기기는 RTX 5060 + Edge 150——제 것보다 더 최신인데도 뻗은 겁니다.
근본 원인은 두 겹이었고, 두 번째 겹은 제 자신이 낸 구멍이었습니다.
- Chromium이 WebGL의 SwiftShader 소프트웨어 폴백을 제거했습니다(Chrome 130에서 폐기 예고, 137에서 제거). 예전에는 하드웨어 context 생성이 실패하면 브라우저가 조용히 CPU 소프트웨어 렌더링으로 내려갔지만, 이제는
getContext()가 곧장null을 돌려줍니다. - 제 앱에는 가 0개였습니다. worker가 실패하면 메인 스레드 Canvas로 fallback하는데, 메인 스레드 WebGL도 죽으면 → React render가 곧장 throw → 아무도 받지 않음 → root 전체가 언마운트됨 → 페이지 전체가 하얗게 됨.
바꿔 말하면, 순수 장식일 뿐인 배경이 페이지 전체를 죽일 수 있는 단일 장애점이 된 것입니다. 과거에는 SwiftShader라는 보이지 않는 안전망이 줄곧 덮어주고 있었는데, 그 그물이 걷히자 구멍이 드러난 겁니다.
IMPORTANT
먼저 고칠 건 성능이 아니라 「장식이 페이지 전체를 죽이지 않게 하는 것」
다음에 기술을 바꾸든 말든, 첫 번째 할 일은 렌더링 스택과 무관합니다. 견고함을 보태는 것입니다. WebGL 감지 + ErrorBoundary + worker 에러 통로——GPU가 전혀 없는 기기는 백화면이 아니라 순수 DOM 특효로 곧장 강등됩니다.
// 배경 장식은 절대 앱 전체를 죽여서는 안 된다.
export default class BackdropErrorBoundary extends Component<Props, State> {
static getDerivedStateFromError(): State {
return { failed: true };
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}측정대 하나: ?debug=perf#
재작성 전에 먼저 숫자가 있어야 했습니다. 아니면 「재작성했더니 더 좋아졌다」 같은 말은 의미가 없습니다. 저는 직접 손으로 만들지 않고, 2026년의 표준 방식인 stats-gl을 붙였습니다——이건 GPU timer query를 써서 진짜 GPU 시간을 잴 수 있고, WebGL / WebGPU 둘 다 지원합니다.
자가 생기자 진단 결론은 아주 분명했습니다. 병목은 전체 화면의 / 대역폭이지, 도 파티클 수도 아니었습니다. 장면 전체의 draw call은 한 손으로 셀 수 있습니다. 진짜로 GPU를 태우고 있던 건——전체 화면 canvas 두 장이 각자 을 하나씩 돌리는 것이었고, 게다가 기본값으로 8x가 켜져 있었습니다. 이것이, 바로 그 60% GPU의 진짜 정체였습니다.
MSAA의 생사 실험#
MSAA가 큰 비중이라면, 그걸 깎으면 어떻게 될까? 저는 세 개 단계를 한 차례 실측했습니다(4070 Ti Super, 180Hz).
MSAA가 전체 GPU 부하의 거의 절반을 차지했습니다. 하지만 여기엔 잔혹한 맞바꿈이 있습니다. MSAA를 깎으면 별이 사라집니다. 픽셀을 세어보니 MSAA 4와 0은 둘 다 가느다란 별의 밝은 핵을 약 절반씩 잃었고(보존율이 46–50%밖에 남지 않음), MSAA 8만 전부 보존했습니다. 이유는 아주 미묘합니다. 한 픽셀보다 작은 그 별점들은, MSAA가 없으면 픽셀 중심을 덮거나(별 전체가 밝음), 덮지 못하거나(별 전체가 사라짐) 둘 중 하나입니다. MSAA의 다중 샘플링이 마침 서브픽셀의 커버리지 감지 역할을 해준 것입니다.
WebGPU는 대체 그럴 값어치가 있나: 세 라운드의 공방#
이건 재작성 전체에서 가장 흥미롭고, 또 가장 자기만족에 빠지기 쉬운 대목입니다. 저는 이걸 온전히 기록해 둡니다. 왜냐하면 저는 여기서 연달아 몇 라운드를 틀렸기 때문입니다.
첫 번째 라운드. 저는 처음에 WebGPU를 「2026년 유일의 구조급 신기술」로 여기고, 당연히 더 빠를 거라고 넘겨짚었습니다.
두 번째 라운드, 검증. 조금 조사해 보니 김이 샜습니다. WebGPU가 주로 이기는 건 CPU 오버헤드와 compute 능력입니다. 그런데 제 비용 구조는 「픽셀당 × 프레임당」의 fill-rate입니다——API를 바꾼다고 제가 픽셀을 하나라도 덜 그리게 되지는 않습니다. 가 아껴주는 CPU 제출은, draw call이 열 개뿐인 장면에서는 0에 수렴합니다. compute 파티클의 이득은 10만 개 급은 되어야 나타납니다. 결론: WebGPU는 제 이 장면의 병목에 이득이 0. 그래서 저는 그걸 냉동해 버렸습니다.
세 번째 라운드, 나 자신 뺨 때리기. 저는 논증에서 PR 하나를 증거로 인용했는데, 나중에 제가 직접 되짚어보니——그건 2024년에 이미 닫힌 RFC였고, 진짜로 three에 병합된 건 다른 번호의 PR이었으며, 그것도 이미 약 r166 무렵에 들어간, 무슨 「새로운 것」이 전혀 아니었습니다.
WARNING
PR을 증거로 인용하기 전에, 먼저 그 상태를 확인하라 이미 닫힌 RFC를 논거로 삼은 건 빼도 박도 못하는 실수입니다. 기술 판단에서, 일차 증거의 「상태」는 그 「내용」만큼이나 중요합니다.
네 번째 라운드, 뒤집기. 결론을 진짜로 뒤엎은 건, 제가 질문 자체를 바꾼 것이었습니다. 제가 줄곧 답하던 문제는 「같은 화면을, WebGPU가 더 싸게 낼 수 있나?」였습니다. 답은 시종 아니오였습니다. 하지만 이렇게 바꾸면: 「같은 GPU 예산으로, WebGPU가 더 많은 화면을 살 수 있나?」——이 질문의 답은 그렇다이고, 그것도 뚜렷했습니다.
- 별을 늘리는 건 거의 공짜: bloom은 고정된 전체 화면 오버헤드라 별 수와 무관하며;
- WebGPU의 compute가 진짜로 사주는 건 「별 10만 개를 전부 살아 움직이게 하는 것」으로, 이건 WebGL로는 도달할 수 없는 품질 상한이며;
- TSL의 selective bloom 덕분에 단일 canvas로 거둬들일 수 있어, 이중 canvas의 합성 세금을 단번에 뽑아냈으며;
- 새로운 는 WebGL2 fallback을 자체 내장하고 있어, 견고함은 공짜로 채워지는 셈입니다.
재작성: 직접 만든 worker entry + Sprite instancing의 피눈물#
먼저 기반을 올렸습니다. three를 r175에서 r185로 올렸습니다. 그러자 첫 번째 구조적 결정이 떠올랐습니다——@react-three/offscreen이라는 그 길은 더 이상 통하지 않았습니다. 그 프로토콜로는 WebGPURenderer가 필요로 하는 async factory를 전달할 수 없었습니다. 이게 바로 Stage 2가 예언했던 「대공사」입니다. 저는 그걸 통째로 갈아치우고, 극도로 간결한 worker entry를 직접 짰습니다. 장면과 렌더링 로직을 전부 lib/starfieldGpu.ts 한 벌에 담았습니다(명령형의 순수 three/webgpu, React 없음, R3F 없음). 이 code는 한 벌로 네 가지를 겸합니다.
그러고 나서, 재작성 전체에서 가장 지독한 지뢰 하나를 밟았습니다. PoC를 돌리자 GPU가 20%까지 떨어져 무척 절약되는 것처럼 보였습니다——그런데 별이 가늘면서 계속 깜빡였고, 게다가 WebGPU backend로 바꾸면 곧장 전부 까맣고 별이 없었지만, WebGL2 fallback은 멀쩡했습니다.
console에 도배되는 에러 하나:
worker 안에서 도배되는 GPUValidationError(발췌)
THREE.AttributeNode: Vertex attribute "uv" not found on geometry.
@ spaceGpuWorker-BiAVyl7F.js
...(같은 메시지가 수천 번 반복)
r185의 타입 문서를 뒤져보고서야 이해했는데, 이 세 증상은 같은 뿌리였습니다.
CAUTION
THREE.Points는 언제나 1px
이건 플랫폼의 하드 제약이라 size가 전부 무효입니다. 그래서: 별이 가늘고(size가 무시됨), 서브픽셀이 픽셀 격자 위에서 튐 = 깜빡임, GPU가 극도로 낮음(사실 quad를 아예 안 그리고 1px 점만 그림). 그리고 pointUV 노드는 GLSL의 gl_PointCoord를 하드코딩하고 있어서 → WGSL로 가면 곧장 검증 에러를 터뜨리고, 물러서서 uv()를 쓰면 이번엔 points geometry에 uv attribute가 없어서 → 전부 0 → 전부 검정.
정답은 three 공식이 지정한 패턴이었습니다. THREE.Points가 아니라 + 을 쓰는 것. 이러면 두 backend 모두 「인스턴스화된 billboard quad」를 거쳐, size가 먹히고 uv()에도 값이 생깁니다——backend 분기가 통째로 잘려나가고, 하나의 경로로 다 해결됩니다.
// WebGPU 上 THREE.Points 永遠 1px,size 全部無效、也沒有 quad uv
const stars = new THREE.Points(geometry, new THREE.PointsNodeMaterial())
// 官方模式:Sprite + instancing,兩個 backend 都走實例化 quad,單一路徑
const mat = new THREE.PointsNodeMaterial()
mat.positionNode = instancedBufferAttribute(new THREE.InstancedBufferAttribute(positions, 3), 'vec3')
const sprite = new THREE.Sprite(mat)
sprite.count = STAR_COUNTquad로 바꾸고 나자 「서브픽셀 격자 튐」에 의한 깜빡임이 뿌리째 사라졌고, 그 「MSAA 0 보존율 ≥ 95%」 인수도 비로소 달성이 가능해졌습니다. 문턱을 넘기까지 수렴하는 데 여섯 번의 반복을 거쳤습니다.
| 반복 | 변경 | 별점 보존 | 밝은 핵 보존 |
|---|---|---|---|
| v0 | 딱딱한 사각형(깜빡임 버전) | — | 깜빡이고 부드럽지 않음 |
| v1 | 부드러운 빛 pow3 | 전부 검정 💀(uv()가 points에서는 0) | — |
| v2 | point uv + pow2.4로 변경 | 19.3% | 22.5% |
| v3 | 밝기 ×2.6 + bloom 반경 확대 | 22.0% | 36.2% |
| v4 | 원반형 부드러운 빛 윤곽 | 31.3% | 68.3% |
| v5 | 별 수 18k → 26k | 58.9% | 97.6% ✓ 문턱 통과 |
감각이 좀처럼 돌아오지 않던 디테일들#
별은 깜빡이지 않게 됐지만, 「예전 버전과 비교하면, 딱 조금 맛이 덜하다」는 느낌이었습니다. 이 대목은 전부 감각의 부채입니다.
- 너무 빨리 깜빡임. 새 파이프라인은 별 26,000개가 전부 깜빡였는데, 옛 스택은 사실 800개만 깜빡였습니다. 26k가 전부 깜빡이면 분주한 「빠른 점멸감」이 생깁니다. 고친 방법: 깜빡임 주기를 10–25초의 느린 호흡으로 늘리고, 진폭은 per-star 분포로 만들었습니다(대다수 별은 거의 움직이지 않고, 소수만 뚜렷하게).
- 안개가 너무 짙고, bloom이 안 맞음. 저는 bloom 파라미터를 「옛것을 그대로 베끼면」 될 줄 알았는데, 알고 보니 pmndrs의
BloomEffect와 three TSL의BloomNode는 서로 다른 두 구현이라, 파라미터 이름조차 서로 맞지 않았습니다. 「파라미터를 베끼는 것」은 「감각을 베끼는 것」과 같지 않으니, 독립적으로 다시 균형을 잡는 수밖에 없었습니다. - 색이 다름(가장 음흉한 것). bloom은 가산 합성이라, 겹치고 나면 canvas의 alpha가 1로 밀려나 → 불투명한 검정이 페이지 본래의 짙은 보라 바탕을 통째로 덮어버렸습니다. 고친 방법은 출력할 때 rgb만 겹치고 alpha는 장면의 원래 값을 유지하는 것입니다.
// bloom 가산 합성이 alpha를 전부 1로 만들면 → 불투명한 검정이 페이지의 짙은 보라 바탕을 덮음(「색이 다름」의 주범)
const combined = scenePassColor.add(bloomPass);
// rgb에는 bloom을 겹치고 alpha는 장면 원래 값 사용 → canvas는 투명을 유지, 페이지의 짙은 보라 바탕 위에 겹침
const pipeline = new THREE.RenderPipeline(renderer, vec4(combined.rgb, scenePassColor.a));
단일 canvas로 거둬들이기#
마지막 단계는 토성까지 안으로 옮겨와, 애초에 뒤집기의 핵심 세일즈 포인트였던 canvas 한 장을 실현하는 것이었습니다. 별밭과 토성은 이제 같은 파이프라인을 공유하며, TSL의 머티리얼별 mrtNode 오버라이드로 selective bloom을 합니다——별의 머티리얼은 bloom 채널에 1을 쓰고, 토성은 전역 기본값인 0을 유지하니, 같은 파이프라인 안에서 두 머티리얼이 서로 다른 bloom 대우를 받습니다.
아래 이 슬라이더는, 왼쪽이 맨 처음 헐벗은 점군만 있던 WebGPU PoC(별이 성기고 토성은 아직 안 들어옴)이고, 오른쪽이 토성을 다시 이식하고 부드러운 빛을 균형 맞춘 뒤의 모습입니다——덧붙이자면, 오른쪽 그 장면이 돌아가는 건 WebGL2 fallback 경로로, 같은 code가 WebGPU 없는 기기에서도 체감이 일치함을 증명합니다.
최종 성과#
재작성 구간 전체는 하루 안에 끝낸 였습니다. 그날 바로 기본값으로 전환되고 옛 스택은 은퇴했으며, 통틀어 20개 commit을 전부 push했습니다.
GPU를 85–90%에서 **25%**로 깎았고, 육안 비교로 99% 유사하며, 게다가 렌더링 경로 안의 React가 0으로 됐습니다——장면은 전부 명령형의 three/webgpu입니다. 그럼 터지던 그 친구 기기는? 이제는 순수 DOM 특효로 우아하게 강등되어, 에러 0에 더 이상 백화면도 없습니다.
마무리: 세 개의 장부#
돌이켜보면, 이 배경의 대장정은 사실 서로 다른 세 층위의 문제였고, 저마다의 교훈이 있었습니다.
- 메인 스레드는 신성하다. Stage 2의 모든 것은 「메인 스레드 위에서 전체 화면 렌더링 루프를 돌리지 마라」를 중심으로 돌았습니다. 파티클 깎기, 애니메이션 멈추기는 다 변두리고, 렌더링을 worker로 옮기는 것이 본질입니다.
- 실기에서, 시크릿 창에서 측정하라. 저는 GPU가 연결되지 않은 점수 측정 환경에 한 구간 내내 속았고, 하마터면 올바른 해법을 revert할 뻔했습니다. 당신 손에 든 그 자가 고장 나 있을 수 있습니다——믿을 만한 걸로 바꾸는 게, 열 번 더 재는 것보다 중요합니다.
- 장식은 단일 장애점이 되어선 안 된다. 순수한 배경 하나가, 앱 전체를 언마운트할 능력을 결코 가져선 안 됩니다.
ErrorBoundary+ 감지 + 강등 경로는, 모든 「기술 자랑」 컴포넌트가 먼저 치러야 할 보험료입니다. - 「성능 최적화」와 「품질 업그레이드」, 두 장부를 구분하라. 같은 WebGPU라도 다른 장부에 적으면 결론이 정반대가 됩니다. 판단하기 전에, 자신이 어느 문제에 답하고 있는지부터 확인하십시오.
아직 다 갚지 못한 부채가 하나 남았습니다. 책장인 그 3D ZeroGravityLibrary가 여전히 옛 fiber / drei / pmndrs 생태계에 매달려 있어서, 그것까지 마이그레이션이 끝나야 완전히 뽑아낼 수 있습니다. 대장정은 아직 종점에 이르지 않았지만, 그 별밭만큼은, 마침내 제가 직접 한 줄 한 줄 써낸 것이 됐습니다.
아직 댓글이 없어요
✨ 첫 댓글을 남겨보세요