vgpu로 블랙홀 세 번째 시도 - WebGPU 래퍼를 붙였더니 쉐이더를 픽셀로 테스트할 수 있게 됐다.
/labs에 블랙홀이 이미 두 개 있다.
두 번째(blackhole-retry)는 어디선가 찾은 소스로 raw WebGPU 돌린 건데, 모양이 좀 아쉬웠다.
강착원반이 뭉개지고, 빛이 휘는 느낌이 약했다.
그러다 마음에 드는 쉐이더 샘플을 하나 찾았고,
Vercel이 만든 vgpu라는 WebGPU 라이브러리도 알게 됐다.
그래서 둘을 섞어 봤다.
결과적으로 제일 크게 얻은 건 블랙홀이 아니라, 쉐이더를 테스트하는 방법이었다.
1. 샘플은 왜 retry보다 멋져 보였을까?
둘 다 방식은 같다.
화면 전체에 fragment shader 하나를 깔고, 픽셀마다 빛줄기를 거꾸로 쏴서 휘게 한다.
차이는 세부였다.
| retry | 샘플 | |
|---|---|---|
| 휘는 힘 | 중심 방향 1/r² (뉴턴 중력 느낌) |
-1.5·h²·p̂/r⁴ (h = 광자 각운동량) |
| 원반 판정 | abs(y) < 0.18이면 매 스텝 누적 |
적도면을 통과할 때만 누적, 통과마다 투과율 ½ |
| 스텝 | 고정 0.05~0.07 | 지평선 근처만 촘촘하게 |
범인은 첫 줄이었다.
각운동량 항이 없으면 빛이 충분히 안 휜다.
그래서 원반 뒤쪽이 그림자 위로 넘어오는 인터스텔라 모양이 안 나온다.
두 번째 줄도 컸다.
두께 안에 있을 때마다 더하면 원반이 뿌옇게 번진다.
평면을 지날 때만 더하면 앞 원반, 뒤 원반, 아랫면이 또렷하게 층으로 갈린다.
2. vgpu는 뭘까?
npm 기준 vgpu@0.5.0이다(2026-09-14 갱신).
WebGPU를 얇게 감싼 라이브러리이고, 브라우저와 headless Node(Dawn) 에서 같은 코드가 돈다.
기본 단위는 effect — 전체 화면 fragment shader 하나다.
const gpu = await init();
const canvasSurface = surface(gpu, canvas, { dpr: [1, 2] });
const fx = effect(gpu, WGSL, { set: { params: { time: 0 } } });
frameLoop(gpu, (frame) => {
fx.set({ params: { time: clock(gpu).time } });
frame.pass(canvasSurface, fx);
});
샘플이 딱 이 모양이다. 쉐이더 하나, uniform 몇 개.
retry가 손으로 짜 둔 어댑터, 파이프라인, 바인드 그룹 코드 수백 줄이 이걸로 대체된다..!
재밌는 건 에이전트용으로 만든 라이브러리라는 점이다.
npx vgpu # 자기 사용법을 출력
npx vgpu docs cat getting-started.md # 패키지에 문서가 같이 들어 있다
npx vgpu examples search bloom # 검증된 예제 검색
npx vgpu examples pull black-hole --out ./ex
npx vgpu doctor # 이 기계가 GPU 렌더를 할 수 있나
문서가 설치한 버전에 묶여 있어서, 기억에 의존할 필요가 없었다.
그리고 예제를 검색하다가 공식 블랙홀 예제가 두 개(black-hole, optimized-black-hole) 있다는 걸 알았다.
MIT다.
3. 모바일 문제 - 한 번 굽고, 매 프레임 칠한다
표현을 낮추더라도 모바일에서도 잘 보였으면 좋겠다고 생각했다.
광선 적분은 픽셀마다 수백 스텝이다.
폰에서 매 프레임 돌리면 무겁다.
답은 optimized-black-hole 예제에 있었다.
관찰 1. 적분 결과는 카메라에만 의존한다
빛이 어디서 원반을 뚫고, 어느 방향으로 탈출하는지는
카메라 위치와 질량이 정한다. 시간과는 상관없다.
원반 무늬가 도는 건 색칠 문제지 경로 문제가 아니다.
그러면 경로는 한 번 구워 두고(G-buffer), 매 프레임은 그 위에 색만 칠하면 된다.
관찰 2. 좌우 회전(yaw)은 굽지 않아도 된다
원반은 y축 대칭이다.
카메라를 y축으로 돌리는 것 = 세상을 반대로 돌리는 것이다.
그래서 yaw는 shade 단계에서 원반 교차점의 방위각과 하늘 방위각에 같은 각을 더하는 것으로 끝난다.
자동회전 중에는 비싼 bake가 한 번도 안 돈다.
다시 굽는 건 위아래 드래그(pitch), 질량 슬라이더, 리사이즈뿐이다.
관찰 3. 도플러 계수도 굽는다
도플러는 dot(원반 접선, 광선 방향)이다.
yaw로 돌리면 두 벡터가 같이 돌아서 내적이 안 변한다.
그래서 이것도 bake에 넣었다.
G-buffer는 32바이트에 딱 맞췄다
WebGPU 기본 한도에서 color attachment는 샘플당 32바이트까지다.rgba32float 두 장이 정확히 32바이트다.
hits : (hit1.xz, hit2.xz) 원반 교차점 두 개. 없으면 (0,0)
sky : (dir.y, 방위각, dop1, dop2) 흡수된 빛은 dir.y 자리에 4.0(범위 밖 값)
플래그 칸이 없어서 "없음"과 "흡수"는 범위 밖 값으로 표시했다.
최종 흐름은 이렇다.
bake(pitch·mass 바뀔 때만) → shade → bright → blur ×N → composite
bake는 needsBake 플래그만 세워 두고, 다음 draw 프레임 맨 앞에서 한 번 인코딩한다.
submit을 따로 하지 않는다.
품질 단계는 둘이다.
| high | low | |
|---|---|---|
| 내부 해상도 | 1배 | 0.6배 |
| bake 스텝 | 420 | 240(스텝 1.7배) |
| fbm 옥타브 | 4 | 2 |
| 블룸 | 360p, 블러 4번 | 180p, 블러 2번 |
| fps 상한 | 60 | 30 |
터치 기기와 짧은 변 768px 미만은 low로 시작한다.
데스크톱도 최근 60프레임 중앙값이 예산을 넘으면 한 번만 low로 내린다.
중앙값이라 첫 컴파일 같은 짧은 튐에는 반응하지 않는다.
4. 쉐이더를 픽셀로 테스트한다
이게 이번에 제일 좋았던 부분이다.
npx vgpu doctor를 돌렸더니 이렇게 나왔다.
Verdict: healthy
Adapter: Metal driver on macOS Version 26.6.2
[OK] render: Rendered and read back a 16x16 offscreen target
맥에서는 Node가 실제 Metal GPU로 렌더하고 픽셀을 읽어 온다.
그러면 vitest 안에서도 된다.
import { frame, target } from "vgpu";
import { init } from "vgpu/node";
const gpu = await init();
const pipeline = createPipeline(gpu, { size: [240, 160], quality });
const output = target(gpu, { size: [240, 160], format: "rgba8unorm" });
pipeline.bake({ pitch: 0.18, mass: 0.5 });
frame(gpu, (f) => pipeline.draw(f, output, { yaw: 0, time: 1 }));
const pixels = await output.color.read({ mipLevel: 0, region: "all" });
vgpu의 effect·target에 vgpu/node에서 만든 gpu를 그대로 넘기면 된다.
브라우저용 파이프라인 모듈을 한 줄도 안 고치고 테스트에서 import한다.
테스트는 "보기 좋다"가 아니라 "이 모양이 있다"를 숫자로 묻는다.
- 그림자 자리의 밝기가 0.03 미만인가
- 그림자 위 세로줄에 0.5 넘는 밝기가 있는가 — 휘어 올라온 뒤쪽 원반
- 그림자 아래에도 있는가 — 아랫면 고리
- 좌우 같은 위치의 원반 띠 밝기 비가 1.15를 넘는가 — 도플러
high와 low를 describe.each로 둘 다 돌린다.
"모바일도 모양은 남긴다"가 요구사항이었으니, 그게 테스트가 된다.
테스트가 헛돌지 않는지도 확인했다
통과하는 테스트는 아무것도 안 잡고 있을 수도 있다.
그래서 일부러 망가뜨려 봤다.
- 휘는 힘을
0.0 *으로 끄면 → 그림자·렌즈 테스트가 실패 - 도플러 빔을 끄면 → 도플러 테스트만 실패
둘 다 기대한 테스트만 빨갛게 됐다. 원본은 되돌렸다.
첫 실패는 테스트가 틀렸다
처음 쓴 테스트는 "화면 정중앙이 검다"였다. 실패했다(0.098).
코드를 의심하기 전에 프레임을 PPM으로 떠서 sips로 PNG로 바꿔 봤다.
이미지가 이미 맞았다.
pitch 0.18에서는 앞쪽 원반 띠가 화면 중앙을 가로지르고, 그림자는 그 위에 걸린다.
인터스텔라 포스터를 떠올리면 당연한 구도인데, 머릿속 그림이 틀렸던 거다.
테스트 좌표를 HEIGHT * 0.38로 옮겼다.
픽셀을 먼저 봤기 때문에 멀쩡한 쉐이더를 고치러 가지 않았다.
5. 끼워 맞추며 부딪힌 것들
pnpm의 build 승인 요구 - 꺼도 됐다
pnpm add vgpu를 하면 이렇게 멈춘다.
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @vgpu/adapter-node@0.5.0, webgpu@0.4.0
webgpu는 Dawn 바이너리다. 승인해야 하나 싶었는데, 승인 없이도 doctor가 healthy였다.webgpu 패키지에 prebuild가 들어 있다.
그래서 pnpm-workspace.yaml의 allowBuilds에 둘 다 false로 박았다.
Vercel 빌드에서 Dawn을 받으러 가지 않는다.
.wgsl 로더 대신 TS 문자열
vgpu는 Next.js용 Turbopack 로더(@vgpu/wgsl/loader-webpack)를 준다.
안 썼다.
- pnpm은 전이 의존성을 루트
node_modules에 안 올린다. 로더 이름 해석이 걸릴 수 있다. - 문서에 명시돼 있다:
next build/next dev는 WGSL을 검증하지 않는다. 로더가 있어도 잘못된 쉐이더가 그대로 배포된다. - 어차피 검증은 headless 픽셀 테스트가 한다.
그래서 쉐이더를 export const BAKE_WGSL = /* wgsl */ \...`로 두고effect()`에 문자열로 넘겼다.
next.config는 한 줄도 안 건드렸다.
uv가 위에서 시작한다
vgpu effect의 uv는 왼쪽 위가 (0,0) 이다. WebGPU 텍스처 좌표와 같다.
샘플은 WebGL(gl_FragCoord, 아래가 0)이라 경계에서 한 번만 뒤집었다.
let screen = vec2f(uv.x - 0.5, 0.5 - uv.y) * vec2f(aspect, 1.0);
WebGPU는 HTTPS에서만 켜진다
폰으로 같은 와이파이의 http://192.168.x.x:3000을 열면 navigator.gpu가 없다.
WebGPU는 secure context 전용이다.
폰 확인은 Vercel Preview(HTTPS)로 했다.
6. 정리
vgpu를 붙여서 얻은 건 코드 절약보다, 쉐이더를 눈이 아니라 숫자로 확인하는 루프였다.
- 블랙홀이 멋져 보이는지는 휘는 힘 공식(각운동량 항)과 원반을 평면 통과 시에만 더하는 데서 갈린다.
- 비싼 적분은 카메라에만 의존한다. 한 번 굽고 매 프레임 칠하면 모바일도 돈다.
- 축대칭이면 yaw 회전은 방위각 덧셈이다. 도플러처럼 회전에 불변인 값은 같이 굽는다.
vgpu/node는 맥에서 실제 Metal로 렌더한다. 브라우저 파이프라인을 그대로 vitest에 넣고 픽셀로 단정한다.- 통과한 테스트는 일부러 망가뜨려서 빨개지는지 본다.
- 테스트가 실패하면 코드보다 프레임부터 본다. 이번엔 틀린 게 내 머릿속 구도였다.
덤으로.
- pnpm의
ERR_PNPM_IGNORED_BUILDS가 떠도, 실제로 필요한지doctor로 먼저 확인한다. next build는 WGSL을 검증하지 않는다. 쉐이더의 검증 게이트는 따로 있어야 한다.
결과물은 https://eunoh.top/labs/blackhole-vgpu에 있다.?quality=low를 붙이면 모바일 단계로 볼 수 있다.
'TIL' 카테고리의 다른 글
| [261004 TIL] 우리들의 추억 2편 - 체크포인트 1 (1) | 2026.10.04 |
|---|---|
| [260928 TIL] terraform provider 공유 캐시 사용 (0) | 2026.09.30 |
| [260917 TIL] ANALYZE는 왜 한 시간이 걸렸나 (0) | 2026.09.17 |
| [260913 TIL] Next 16 캐시컴포넌트 - 이게 좋은건지... (1) | 2026.09.13 |
| [260830 TIL] 배럴 export 와 실행 환경 경계 (0) | 2026.08.30 |