WebView 메인 화면에 3D 캐릭터와 단계별 오브젝트를 표시해야 했습니다.
단순히 모델을 보여주는 것뿐 아니라 다음 기능도 필요했습니다.
- 화면 크기에 맞는 Canvas
- GLB 모델과 애니메이션 로딩
- 터치와 드래그 구분
- 상태에 따른 모델 교체
- 특정 프레임 구간만 재생
- 오브젝트를 캐릭터에 부착
- 화면 전환 시 WebGL 리소스 정리
이번 글에서는 Three.js를 React WebView에 연결하고, 모델 상태와 애니메이션을 관리한 과정을 정리해 보려 합니다.
3D 모델을 띄운 뒤에도 신경 써야 했던 부분
React 컴포넌트가 나타나고 사라지는 시점에 맞춰 Three.js를 초기화하고 정리해야 했습니다. WebView 크기에 따라 Canvas를 조절하고, 크기와 위치가 제각각인 GLB 모델을 화면에 맞추는 작업도 필요했습니다.
모델을 띄운 뒤에는 기본 애니메이션과 일회성 애니메이션을 구분하고, 부착 오브젝트만 바뀔 때는 캐릭터를 다시 불러오지 않도록 했습니다. 터치와 회전 동작이 겹치지 않는지, 화면을 닫을 때 GPU 리소스가 정리되는지도 확인했습니다.
전체 구조는 다음과 같습니다.
React Component
↓
Three.js Scene
↓
GLTFLoader
↓
Model + AnimationMixer
↓
Canvas Render
1. React에서 Three.js 초기화
3D 영역을 표시할 DOM을 ref로 가져온 뒤 useEffect에서 Three.js를 초기화했습니다.
const mountRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const mountElement = mountRef.current
if (!mountElement) {
return
}
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(32, 1, 0.01, 100)
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
})
mountElement.replaceChildren(renderer.domElement)
return () => {
renderer.dispose()
}
}, [])
WebView 배경 위에 모델만 표시하기 위해 Renderer에는 투명 배경을 사용했습니다.
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
})
모바일에서 지나치게 높은 해상도로 렌더링되지 않도록 Pixel Ratio도 제한했습니다.
renderer.setPixelRatio(
Math.min(window.devicePixelRatio, 2),
)
고해상도 기기에서 무조건 실제 Pixel Ratio를 사용하면 Canvas 크기와 GPU 사용량이 크게 증가할 수 있기 때문입니다.
2. GLB 모델 로딩
GLB 모델은 GLTFLoader를 이용해 불러왔습니다.
const loader = new GLTFLoader()
loader.load(
modelUrl,
gltf => {
scene.add(gltf.scene)
},
undefined,
() => {
setStatus('error')
},
)
모델 로딩 상태는 React에서 관리했습니다.
const [status, setStatus] = useState<
'loading' | 'ready' | 'error'
>('loading')
모델이 준비되기 전에는 로딩 문구를 표시하고, 실패하면 대체 화면을 표시했습니다.
{status !== 'ready' && (
<div>
{status === 'error'
? '모델을 불러오지 못했습니다.'
: '모델 로딩 중...'}
</div>
)}
WebGL Canvas가 비어 있는 상태로 보이지 않도록 로딩 상태를 별도로 표시했습니다.
3. 모델 크기와 중심점 보정
전달받은 모델마다 원점과 크기가 달랐습니다.
같은 카메라를 사용해도 어떤 모델은 너무 작게 보이고, 어떤 모델은 화면 밖으로 벗어났습니다.
Box3를 이용해 모델의 크기와 중심점을 계산했습니다.
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
model.position.sub(center)
const maxAxis = Math.max(
size.x,
size.y,
size.z,
1,
)
const scale = targetSize / maxAxis
model.scale.setScalar(scale)
모델의 가장 긴 축을 기준으로 Scale을 계산하면서 서로 다른 모델도 비슷한 크기로 표시할 수 있었습니다.
자동 보정 후에도 화면별 연출이 필요한 경우에는 카메라와 모델 위치를 별도 설정으로 관리했습니다.
const viewPresets = {
intro: {
cameraFov: 36,
cameraPosition: { x: 0, y: 0.2, z: 3 },
},
main: {
cameraFov: 24,
cameraPosition: { x: 0, y: 0.3, z: 4 },
},
}
실제 값은 UI에서 모델이 차지해야 하는 영역에 맞춰 조절했습니다.
4. WebView에서 Canvas 크기가 맞지 않았던 문제
Renderer에서는 부모 요소의 크기를 사용하고 있었습니다.
const width = mountElement.clientWidth
const height = mountElement.clientHeight
renderer.setSize(width, height, false)
camera.aspect = width / height
camera.updateProjectionMatrix()
하지만 일부 기기에서 Canvas가 화면 영역을 채우지 못했습니다.
확인해 보니 Renderer의 Resize 로직이 아니라 부모 Wrapper의 고정 크기가 원인이었습니다.
width: min(100%, 320px)
height: clamp(240px, 34vh, 360px)
부모 영역이 제한돼 있었기 때문에 Renderer가 정상적으로 크기를 계산해도 제한된 크기만 사용할 수 있었습니다.
Wrapper를 실제 배치 영역에 맞추도록 변경했습니다.
width: 100%
height: 100%
크기 변경은 ResizeObserver로 감지했습니다.
const resize = () => {
const width = Math.max(1, mountElement.clientWidth)
const height = Math.max(1, mountElement.clientHeight)
renderer.setSize(width, height, false)
camera.aspect = width / height
camera.updateProjectionMatrix()
}
const observer = new ResizeObserver(resize)
observer.observe(mountElement)
WebView 높이와 화면 방향이 변경돼도 현재 부모 크기를 기준으로 Canvas를 다시 계산할 수 있게 됐습니다.
5. 기본 애니메이션과 일회성 애니메이션
모델에는 여러 개의 Animation Clip이 포함돼 있었습니다.
기본 상태에서는 반복 애니메이션을 실행하고, 특정 행동이 발생하면 일회성 애니메이션을 재생해야 했습니다.
const mixer = new THREE.AnimationMixer(model)
const actions = new Map<string, THREE.AnimationAction>()
for (const clip of gltf.animations) {
actions.set(
clip.name,
mixer.clipAction(clip),
)
}
기본 애니메이션은 반복 재생했습니다.
idleAction.setLoop(THREE.LoopRepeat, Infinity)
idleAction.play()
일회성 애니메이션은 한 번만 실행하고 마지막 상태를 유지했습니다.
action.reset()
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
action.play()
일회성 애니메이션이 끝나면 다시 기본 애니메이션으로 전환했습니다.
mixer.addEventListener('finished', event => {
if (event.action !== activeAction) {
return
}
activeAction.stop()
activeAction = null
playIdle()
})
애니메이션 전환이 갑자기 끊겨 보이지 않도록 짧은 Fade도 적용했습니다.
idleAction.fadeOut(0.08)
nextAction.fadeIn(0.08).play()
6. Animation Clip 이름이 일치하지 않는 문제
모델 제작 도구와 Export 방식에 따라 Clip 이름 형식이 달랐습니다.
Idle
Character|Idle
idle_action
idle action
코드에서 하나의 이름만 찾으면 모델 파일이 변경됐을 때 애니메이션을 찾지 못할 수 있었습니다.
Clip을 등록할 때 여러 Alias를 함께 만들었습니다.
function createAliases(name: string) {
const normalized = name.toLowerCase()
const tail = name.split('|').at(-1)?.toLowerCase()
return new Set([
normalized,
tail,
tail?.replaceAll('_', ''),
tail?.replaceAll('_', ' '),
])
}
각 Alias가 같은 Animation Action을 바라보도록 저장했습니다.
for (const alias of createAliases(clip.name)) {
actionsByAlias.set(alias, action)
}
모델 파일의 Clip 이름 형식이 조금 달라져도 동일한 애니메이션을 찾을 수 있게 됐습니다.
7. 하나의 Clip에서 필요한 프레임만 재생
일부 모델은 여러 동작이 하나의 Animation Clip에 포함돼 있었습니다.
0 ~ 40 frame: 첫 번째 동작
41 ~ 80 frame: 두 번째 동작
81 ~ 120 frame: 대기 동작
Clip 전체를 실행하면 필요하지 않은 동작까지 이어서 재생됐습니다.
처음에는 Animation Action의 실행 시간만 조절하려 했지만 모델 교체나 재실행 시 시작 위치와 종료 시점을 안정적으로 관리하기 어려웠습니다.
AnimationUtils.subclip을 이용해 필요한 프레임 구간만 별도의 Clip으로 만들었습니다.
const segmentClip = THREE.AnimationUtils.subclip(
sourceClip,
`${sourceClip.name}-segment`,
startFrame,
endFrame,
30,
)
const segmentAction = mixer.clipAction(segmentClip)
생성한 Action은 같은 프레임 구간을 다시 사용할 수 있도록 저장했습니다.
const key = `${clipName}:${startFrame}-${endFrame}`
segmentActions.set(key, segmentAction)
상태에 따라 필요한 구간만 전달했습니다.
const animationRange =
actionType === 'first'
? { startFrame: 0, endFrame: 40 }
: { startFrame: 41, endFrame: 80 }
모델 파일을 동작별로 분리하지 않고 하나의 Clip에서 필요한 부분만 재생할 수 있었습니다.
8. 캐릭터에 오브젝트 부착
단계에 따라 캐릭터가 들고 있는 오브젝트를 변경해야 했습니다.
캐릭터 모델 전체를 단계별로 만들면 같은 Mesh와 Animation 데이터가 반복됩니다.
캐릭터 모델은 유지하고 특정 Bone에 오브젝트를 추가하는 방식으로 구성했습니다.
const attachmentTarget =
model.getObjectByName('AttachmentPoint')
attachmentTarget?.add(attachmentModel)
오브젝트별 위치, 회전, 크기는 설정으로 관리했습니다.
const attachmentConfig = {
position: { x: 0.1, y: -0.1, z: 0 },
rotation: { x: 60, y: 0, z: 30 },
scale: 0.9,
}
Three.js의 회전 값은 Radian을 사용하므로 설정에서 Degree를 사용할 때는 변환했습니다.
attachment.rotation.set(
THREE.MathUtils.degToRad(config.rotation.x),
THREE.MathUtils.degToRad(config.rotation.y),
THREE.MathUtils.degToRad(config.rotation.z),
)
단계가 변경되면 기존 오브젝트만 제거하고 새로운 오브젝트를 같은 위치에 연결했습니다.
9. 오브젝트 변경마다 Canvas가 다시 생성된 문제
초기에는 모델 URL과 부착 오브젝트 URL을 함께 Canvas 로드 Key로 사용했습니다.
return `${modelUrl}:${attachmentUrl}`
부착 오브젝트가 변경될 때마다 React Effect가 다시 실행되면서 다음 작업이 모두 반복됐습니다.
- Renderer 생성
- Scene 생성
- Camera 생성
- 캐릭터 모델 재요청
- AnimationMixer 재생성
- 기존 WebGL 리소스 제거
캐릭터는 그대로인데 부착 오브젝트만 변경돼도 Canvas 전체가 다시 만들어졌습니다.
오브젝트 변경 시 모델이 잠깐 사라지는 현상을 확인한 뒤 Effect 의존성과 로드 Key를 비교했습니다.
Canvas 재생성 기준을 캐릭터 모델 URL로 제한했습니다.
function getCanvasLoadKey({
modelUrl,
}: {
modelUrl: string
}) {
return modelUrl
}
부착 오브젝트는 별도의 로더와 교체 함수에서 처리했습니다.
캐릭터 모델 변경
→ Canvas와 Scene 다시 생성
부착 오브젝트 변경
→ 기존 Scene에서 오브젝트만 교체
이렇게 나누고 나니 단계가 바뀌어도 Renderer와 캐릭터 모델은 그대로 유지됐습니다.
10. 터치와 드래그 구분
모델을 좌우로 드래그하면 회전하고, 짧게 터치하면 애니메이션을 실행하도록 구성했습니다.
Pointer Down에서 시작 위치를 저장합니다.
let pointerStartX = 0
let pointerMoved = false
function handlePointerDown(event: PointerEvent) {
pointerStartX = event.clientX
pointerMoved = false
element.setPointerCapture(event.pointerId)
}
Pointer Move에서 일정 거리 이상 이동했는지 확인합니다.
function handlePointerMove(event: PointerEvent) {
const moved = Math.abs(
event.clientX - pointerStartX,
)
if (moved > 6) {
pointerMoved = true
}
model.rotation.y += event.movementX * 0.01
}
Pointer Up에서 이동하지 않은 경우만 터치로 판단했습니다.
function handlePointerUp() {
if (!pointerMoved) {
playTapAnimation()
onTap?.()
}
}
단순히 click과 pointermove를 각각 처리하면 드래그가 끝난 뒤 Click까지 실행될 수 있습니다.
그래서 같은 Pointer 흐름 안에서 이동 여부를 확인해, 드래그한 뒤에는 터치 동작이 실행되지 않도록 했습니다.
11. 디버깅 패널 구성
3D 모델은 코드의 숫자만 수정하며 위치를 맞추기 어려웠습니다.
개발 환경에서 다음 값을 실시간으로 변경할 수 있는 디버깅 패널을 추가했습니다.
- 모델 선택
- Animation Clip 선택
- 카메라 FOV
- 카메라 위치와 회전
- 모델 위치와 회전
- 조명 위치와 밝기
- 부착 오브젝트 위치와 회전
- 부착 오브젝트 크기
- 재생할 프레임 구간
값을 변경하면 현재 Scene에 바로 적용했습니다.
camera.position.set(x, y, z)
camera.fov = fov
camera.updateProjectionMatrix()
모델을 다시 Export하거나 매번 코드를 수정하지 않고 화면에서 적절한 값을 확인한 뒤 설정으로 옮길 수 있었습니다.
모델 파일을 교체했을 때는 Object Tree와 Animation Clip 목록도 출력했습니다.
console.table(
gltf.animations.map(clip => ({
name: clip.name,
duration: clip.duration,
tracks: clip.tracks.length,
})),
)
애니메이션이 실행되지 않을 때 코드보다 먼저 모델에 실제 Clip이 포함돼 있는지 확인할 수 있었습니다.
12. WebGL 리소스 정리
화면을 이동하거나 모델을 교체할 때 기존 Animation Frame과 WebGL 리소스를 정리해야 했습니다.
return () => {
cancelAnimationFrame(frameId)
resizeObserver.disconnect()
scene.traverse(object => {
const mesh = object as THREE.Mesh
mesh.geometry?.dispose()
if (Array.isArray(mesh.material)) {
mesh.material.forEach(material => {
material.dispose()
})
} else {
mesh.material?.dispose()
}
})
renderer.dispose()
}
Pointer Event도 함께 제거했습니다.
element.removeEventListener(
'pointerdown',
handlePointerDown,
)
element.removeEventListener(
'pointermove',
handlePointerMove,
)
React 컴포넌트만 제거하고 Animation Loop를 중단하지 않으면 보이지 않는 화면에서도 렌더링이 계속될 수 있습니다.
정리
3D 모델을 화면에 띄우는 것보다, 상태가 바뀔 때 무엇을 유지하고 무엇을 다시 만들어야 하는지 정하는 부분에 더 신경을 썼습니다.
모델 크기와 중심점을 맞추고 ResizeObserver로 Canvas 크기를 조절한 뒤에도, 애니메이션 전환과 프레임 구간 재생, 오브젝트 교체, 터치와 드래그 구분을 각각 처리해야 했습니다. 화면을 떠날 때 WebGL 리소스를 정리하는 작업도 필요했습니다.
특히 부착 오브젝트만 바뀌는데 Canvas 전체를 다시 만들고 있던 부분이 문제였습니다. 캐릭터와 오브젝트의 변경 기준을 나누면서 불필요한 재생성을 줄일 수 있었습니다.
이후에는 모델이나 단계가 추가돼도 Renderer를 다시 구성하지 않고, 모델 URL과 Animation Clip, 부착 설정을 추가해 같은 Canvas를 사용할 수 있게 됐습니다.