Remotion에서 ShaderToy 멀티패스 이펙트: 단일 비디오에서 컨테이너 패턴으로의 진화
비디오에 쉐이더를 끼얹다가 생긴 일
“Remotion 비디오에 ShaderToy 효과를 적용한다.” 이런 식의 요청을 받으면, 겉으로 보기에는 간단해 보이지 않겠는가. 하지만 개발자의 눈에는 이미 수많은 난관과 삽질의 그림자가 아른거린다. 하나의 비디오에 필터 효과를 입히는 것과, 여러 비디오를 조화롭게 배치하는 것은 완전히 다른 차원의 문제.
1. 작업 요약: “단순한 비디오 필터”가 왜 지옥이 되는가
처음엔 단순했다. 비디오 프레임을 쉐이더에 텍스처로 넘겨서 픽셀 값을 건드리면 될 일이라고 생각했다. 그러나 현실은 달랐다. 단일 패스 쉐이더 변환에서 시작해, 멀티패스, WebGL, React 훅, 그리고 Remotion의 비디오 파이프라인까지, 온갖 기술적 난제들이 꼬리에 꼬리를 물고 이어졌다.
작업 흐름 요약
-
“Bead Pixelator” 단일 패스 변환: 몸풀기 단계였다. “Bead Pixelator” 같은 단순한 쉐이더 코드를 WebGL/Three.js GLSL로 변환하는 작업. 걷는 법을 배우는 것과 같았다.
-
파이프라인 탐색: 쉐이더를 비디오 위에 겹칠 것인가, 아니면 비디오 자체를 필터링할 것인가. 이 순진한 고민은
<OffthreadVideo>를 통해 비디오 프레임을 텍스처로 만들어 쉐이더에 전달하는 올바른 파이프라인을 찾으면서 끝이 났다. -
멀티패스 도전, 지옥의 시작: “EigenVapor”라는 3-pass 쉐이더에 도전한 것은 이 바닥의 진정한 고통을 맛보겠다는 선언과도 같았다. FBO(프레임버퍼 객체), 피드백 루프, React 훅 규칙 위반 등 수많은 오류가 발생하며 멘탈은 산산조각이 났다.
-
외부 성공 사례 분석: 결국 답은 바닐라 자바스크립트 예제에서 찾게 되었다. 핑퐁 버퍼링과 3D 씬 환경의 정확한 복제가 핵심임을 깨달았다.
2. 아키텍처가 전부였다
| 💡 깨달음의 순간 |
|---|
| 1. 아키텍처가 전부다: GLSL 코드 변환은 시작일 뿐이다. 진정한 싸움은 안정적이고 예측 가능한 렌더링 프레임워크를 먼저 구축하는 것에 있었다. 기초 공사 없이 지붕부터 올리려 했으니 망할 수밖에. |
2. React 훅 규칙은 신성불가침: useFrame, useFBO 같은 R3F 훅은 <Canvas> 내부에, 조건문 없이, 항상 같은 순서로 호출되어야 한다. 이를 어기면 React의 상태 관리 시스템이 무너지고, 모든 것이 실패한다. 예외는 없었다. |
| 3. 시각적 디버깅의 중요성: 검은 화면은 아무 정보도 주지 않는다. 아무것도 나오지 않는 그 검은 공허는 개발자에게는 가장 치명적인 침묵이다. 각 렌더링 패스의 중간 결과를 직접 화면에 그려볼 수 있는 디버깅 기능은 선택이 아닌 필수다. |
| 4. 성공 사례의 완벽한 복제: 외부 코드를 변환할 때는 쉐이더 코드뿐만 아니라, 카메라 위치, 오브젝트 위치, 씬 구성 등 실행 환경 전체를 1:1로 정확하게 복제해야만 동일한 결과를 얻을 수 있다. ‘어떻게든 되겠지’라는 안일한 생각은 결국 ‘아, 안 되는구나’라는 처절한 깨달음으로 돌아왔다. |
3. 주요 이슈와 해결책
아래는 여러 증상의 기록과 그 해결책이다.
| 이슈 (삽질 단계) | 증상 (빡침의 흔적) | 해결책 (인지는 모르겠지만 어쨌든 솔루션) |
|---|---|---|
| 초기 | 비디오 경로 오류, 오디오 싱크 불일치 | staticFile()과 <OffthreadVideo>로 해결. 문서를 제대로 읽을 걸… |
| 중기 | “Hooks can only be used within the Canvas” | 모든 훅을 <Canvas> 내부 단일 “코어” 컴포넌트로 중앙화하여 해결. |
| 후기 | undeclared identifier, 함수 누락 |
누락된 uniform 선언 및 헬퍼 함수 정의를 모두 포함. |
| 지속적 | program not valid, Feedback loop |
각 패스가 독립적인 씬과 메쉬를 갖도록 아키텍처 재설계. |
| 최종 | 모든 에러가 사라진 검은 화면 | 검은 공포. 모든 논리가 무너지는 침묵. 버퍼와 씬을 분리함 |
4. 전체 프로젝트 구성 및 데이터 흐름
결국 이 모든 삽질은 단단하고 재사용 가능한 프레임워크를 만들기 위한 과정이었다. 당신의 피와 땀으로 만들어진 건축물의 설계도인 셈이다.
파일 구조 📂
src/
├── components/
│ ├── MultiPassRenderer.tsx # 고통 끝에 완성된 안정적 렌더링 프레임워크
│ └── EigenVaporEffect.tsx # 프레임워크를 사용하는 특정 효과 컴포넌트
│
├── remotion/
│ └── MyComposition.tsx # 당신의 고통이 비디오로 렌더링될 곳
│
└── public/
└── my-video.mp4 # 당신의 삽질에 사용된 비디오 소스
데이터 흐름 (Mermaid)
graph TD
A["Remotion Composition (MyComposition.tsx)"] -->|videoPath, other props| B["효과 컴포넌트 (EigenVaporEffect.tsx)"];
B --> C["렌더링 프레임워크 (MultiPassRenderer.tsx)"];
subgraph "C [MultiPassRenderer.tsx]"
direction LR
C1["<OffthreadVideo> (hidden)"] -- "매 프레임 이미지 데이터" --> C2["videoTexture (state)"];
C3["<Canvas>"];
C2 -- "texture prop" --> C4["<RendererCore>"];
C3 --> C4;
end
subgraph "C4 [<RendererCore> 내부]"
direction TB
D1["useFBO() -> FBOs 생성"];
D2["Context.Provider (FBOs, videoTexture)"];
D1 --> D2;
end
B -- "자식으로 Pass 컴포넌트들 전달" --> D2
subgraph "렌더링 파이프라인"
direction LR
P1["<Pass index=0 shader=shaderA>"] -- "FBO[0]에 쓰기" --> FBO0["FBO[0] (Buffer A 결과)"];
P2["<Pass index=1 shader=shaderB>"] -- "FBO[1]에 쓰기" --> FBO1["FBO[1] (Buffer B 결과)"];
subgraph "입력"
C2_dup["videoTexture"] -- "iChannel0" --> P1;
FBO0 -- "iChannel1" --> P3["<Pass isFinal shader=shaderImage>"];
FBO1 -- "iChannel2" --> P3;
end
P3 -- "최종 결과" --> Screen["화면 (Screen)"];
end
D2 --> P1 & P2 & P3