03. 에셋, 성능, 디버깅, 배포
이 문서는 Three.js/R3F를 실제 프론트 프로젝트에 붙일 때 필요한 운영 감각을 다룬다. GLB 모델, texture, shader, resource cleanup, performance, smoke test까지 하나의 배포 흐름으로 묶는다.
1. 모델 파일 파이프라인
웹 제품 화면에서는 OBJ/FBX보다 GLB가 다루기 쉽다.
Blender / 3D tool
-> origin 정리
-> scale 적용
-> Y-up 기준 확인
-> mesh name 정리
-> texture 포함 여부 확인
-> GLB export
-> public/models/product.glb
모델 준비 체크:
origin이 제품 중심 근처인가
scale이 웹 장면 기준으로 너무 작거나 크지 않은가
axis 방향이 맞는가
mesh name이 앱 도메인으로 매핑 가능한가
texture 경로가 안정적인가
bounding box가 기대한 크기인가
mesh name은 UI 상태와 연결되므로 중요하다.
type ProductPart = 'body' | 'sole' | 'laces' | 'logo'
const MODEL_PART_BY_NODE_NAME: Record<string, ProductPart> = {
Shoe_Body: 'body',
Shoe_Heel: 'body',
Shoe_Sole: 'sole',
Shoe_Lace_1: 'laces',
Shoe_Lace_2: 'laces',
Shoe_Logo: 'logo',
}
2. GLB 로딩
Vite 기준으로 public/models/shoe.glb는 런타임에서 /models/shoe.glb로 접근한다.
import { useGLTF } from '@react-three/drei'
import * as THREE from 'three'
type ProductGLTF = {
nodes: Record<string, THREE.Mesh>
materials: Record<string, THREE.Material>
}
export function ProductModel() {
const gltf = useGLTF('/models/shoe.glb') as unknown as ProductGLTF
const upper = gltf.nodes.Shoe_Upper
return (
<group dispose={null}>
<mesh geometry={upper.geometry} castShadow receiveShadow>
<meshStandardMaterial color="#2563eb" />
</mesh>
</group>
)
}
useGLTF.preload('/models/shoe.glb')
dispose={null}은 loader cache가 가진 리소스를 subtree unmount 때 R3F가 임의로 dispose하지 않게 하는 패턴이다.
3. GLB geometry와 React-controlled material
제품 커스터마이저에서는 geometry는 GLB에서 가져오고, material은 React state로 다시 선언하는 패턴이 좋다.
import { useGLTF } from '@react-three/drei'
import type { ThreeEvent } from '@react-three/fiber'
import * as THREE from 'three'
type ProductPart = 'upper' | 'sole' | 'lace' | 'logo'
const PRODUCT_PARTS: Record<ProductPart, { label: string; nodeName: string }> = {
upper: { label: 'Upper', nodeName: 'Shoe_Upper' },
sole: { label: 'Sole', nodeName: 'Shoe_Sole' },
lace: { label: 'Lace', nodeName: 'Shoe_Lace' },
logo: { label: 'Logo', nodeName: 'Shoe_Logo' },
}
type ProductGLTF = {
nodes: Record<string, THREE.Mesh>
}
export function GltfProductModel({
selectedPart,
partColors,
onSelectPart,
}: {
selectedPart: ProductPart
partColors: Record<ProductPart, string>
onSelectPart: (part: ProductPart) => void
}) {
const gltf = useGLTF('/models/shoe.glb') as unknown as ProductGLTF
return (
<group dispose={null} scale={1.65} position={[0, -0.48, 0]}>
{(Object.keys(PRODUCT_PARTS) as ProductPart[]).map((part) => {
const meta = PRODUCT_PARTS[part]
const node = gltf.nodes[meta.nodeName]
if (!node) return null
function select(event: ThreeEvent<MouseEvent>) {
event.stopPropagation()
onSelectPart(part)
}
return (
<mesh key={part} geometry={node.geometry} castShadow receiveShadow onClick={select}>
<meshStandardMaterial
color={partColors[part]}
emissive={selectedPart === part ? partColors[part] : '#000000'}
emissiveIntensity={selectedPart === part ? 0.18 : 0}
/>
</mesh>
)
})}
</group>
)
}
바뀌는 것은 geometry 공급원뿐이다.
boxGeometry -> gltf.nodes[nodeName].geometry
React state, click event, material 연결은 그대로 유지된다.
4. Suspense와 fallback
GLB, HDR, 큰 texture는 비동기로 도착한다. 빈 Canvas를 막기 위해 fallback을 둔다.
import { Suspense } from 'react'
function ModelFallback() {
return (
<mesh position={[0, 0.65, 0]}>
<boxGeometry args={[1.8, 0.8, 0.8]} />
<meshBasicMaterial wireframe color="#94a3b8" />
</mesh>
)
}
function ProductScene() {
return (
<Suspense fallback={<ModelFallback />}>
<ProductModel />
</Suspense>
)
}
DOM fallback과 3D fallback은 다르다.
DOM fallback: Canvas/chunk가 아직 준비되지 않은 상태
3D fallback: Canvas 안에서 모델을 기다리는 상태
5. Texture
Texture는 3D 표면에 입히는 이미지 데이터다. CSS background-image처럼 느껴질 수 있지만 UV, wrap, repeat, filtering, material slot이 함께 작동한다.
function createStripeTexture(color: string) {
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D context가 필요합니다.')
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, 128, 128)
ctx.fillStyle = color
for (let x = -128; x < 128; x += 24) {
ctx.fillRect(x, 0, 12, 128)
}
const texture = new THREE.CanvasTexture(canvas)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(2, 2)
texture.center.set(0.5, 0.5)
return texture
}
function PatternMaterial({ color }: { color: string }) {
const map = useMemo(() => createStripeTexture(color), [color])
useEffect(() => {
return () => {
map.dispose()
}
}, [map])
return <meshStandardMaterial color="#ffffff" map={map} roughness={0.35} />
}
주의:
repeat을 쓰려면 wrapS/wrapT를 RepeatWrapping으로 설정한다.
직접 만든 texture는 cleanup에서 dispose한다.
texture 색을 그대로 보고 싶으면 material color는 흰색에 가깝게 둔다.
6. Shader와 uniform
Material prop 조합으로 부족할 때 shader를 쓴다. Shader는 GPU에서 정점과 픽셀을 계산하는 코드다.
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const fragmentShader = `
uniform float uTime;
uniform float uMix;
varying vec2 vUv;
void main() {
vec3 blue = vec3(0.15, 0.35, 1.0);
vec3 orange = vec3(1.0, 0.45, 0.05);
float pulse = 0.5 + 0.5 * sin(uTime * 2.0);
vec3 color = mix(blue, orange, vUv.y * uMix + pulse * 0.2);
gl_FragColor = vec4(color, 1.0);
}
`
R3F에서 uniform을 갱신한다.
function ShaderSurface({ mix }: { mix: number }) {
const materialRef = useRef<THREE.ShaderMaterial>(null)
useFrame((state) => {
if (!materialRef.current) return
materialRef.current.uniforms.uTime.value = state.clock.elapsedTime
})
return (
<mesh>
<planeGeometry args={[2, 2, 64, 64]} />
<shaderMaterial
ref={materialRef}
uniforms={{
uTime: { value: 0 },
uMix: { value: mix },
}}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
/>
</mesh>
)
}
흐름:
React state -> uniform -> shader -> GPU pixel
7. 성능 기준
Three.js 성능은 React 성능과 다른 축을 가진다.
draw call
Object3D count
triangle count
texture size
DPR
shadow map
postprocessing
React state와 useFrame 경계
같은 geometry/material을 수백 번 반복한다면 InstancedMesh를 고려한다.
function InstancedBoxes() {
const ref = useRef<THREE.InstancedMesh>(null)
const matrix = useMemo(() => new THREE.Matrix4(), [])
useEffect(() => {
const mesh = ref.current
if (!mesh) return
for (let index = 0; index < 100; index += 1) {
const x = (index % 10) - 4.5
const z = Math.floor(index / 10) - 4.5
matrix.makeTranslation(x, 0.5, z)
mesh.setMatrixAt(index, matrix)
}
mesh.instanceMatrix.needsUpdate = true
}, [matrix])
return (
<instancedMesh ref={ref} args={[undefined, undefined, 100]}>
<boxGeometry args={[0.35, 0.35, 0.35]} />
<meshStandardMaterial color="#38bdf8" />
</instancedMesh>
)
}
성능 판단:
리소스 객체 수를 줄이고 싶다 -> geometry/material 공유
draw call을 줄이고 싶다 -> InstancedMesh
픽셀 계산 비용을 줄이고 싶다 -> DPR/shadow/postprocessing 조절
React 리렌더 비용을 줄이고 싶다 -> useFrame에서 매 프레임 setState 금지
8. Resource lifecycle
직접 만든 geometry, material, texture는 GPU 리소스를 잡을 수 있다.
function ManualResource() {
const geometry = useMemo(() => new THREE.BoxGeometry(1, 1, 1), [])
const material = useMemo(
() => new THREE.MeshStandardMaterial({ color: '#2563eb' }),
[],
)
useEffect(() => {
return () => {
geometry.dispose()
material.dispose()
}
}, [geometry, material])
return <mesh geometry={geometry} material={material} />
}
기준:
JSX로 선언한 geometry/material: 보통 R3F가 unmount 때 정리
useMemo/new THREE로 직접 만든 리소스: cleanup 고려
useGLTF loader cache: dispose={null} 패턴 고려
9. 디버깅 루틴
빈 화면을 보면 GLB 탓으로 바로 가지 않는다.
1. Canvas wrapper 크기
2. DebugBoundary wireframe
3. Camera position/target
4. near/far clipping
5. light/material 관계
6. GLB path
7. nodeName
8. scale/origin/bounding box
GLB 진단 코드:
useEffect(() => {
const box = new THREE.Box3().setFromObject(gltf.scene)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
console.log({
size: size.toArray(),
center: center.toArray(),
nodes: Object.keys(gltf.nodes),
})
}, [gltf])
판단:
size가 너무 작다 -> scale 문제
center가 원점에서 멀다 -> origin/camera target 문제
nodes에 기대한 이름이 없다 -> mesh name 매핑 문제
basic material은 보인다 -> light/material 문제
wireframe도 안 보인다 -> camera/canvas/clipping 문제
10. 프론트 통합
라우트 컴포넌트는 HTML UI와 Canvas를 함께 소유한다.
import { Suspense, lazy, useState } from 'react'
const ProductCanvas = lazy(() => import('./ProductCanvas'))
export function ProductRoute() {
const [selectedPart, setSelectedPart] = useState<ProductPart>('upper')
const [partColors, setPartColors] = useState(INITIAL_PART_COLORS)
return (
<main className="product-route">
<section className="product-canvas-shell" aria-label="3D product preview">
<Suspense fallback={<div role="status">Loading 3D preview</div>}>
<ProductCanvas
selectedPart={selectedPart}
partColors={partColors}
onSelectPart={setSelectedPart}
/>
</Suspense>
</section>
<ColorPanel
selectedPart={selectedPart}
partColors={partColors}
onSelectPart={setSelectedPart}
onChangeColor={(color) =>
setPartColors((prev) => ({
...prev,
[selectedPart]: color,
}))
}
/>
</main>
)
}
SSR 프레임워크에서는 Canvas를 client-only 경계에 둔다.
'use client'
import { Canvas } from '@react-three/fiber'
export function ProductCanvas() {
return <Canvas>{/* scene */}</Canvas>
}
11. Smoke test
DOM 테스트만으로는 WebGL 장면이 실제로 그려졌는지 알기 어렵다. Canvas screenshot 픽셀 분포를 검사한다.
import assert from 'node:assert/strict'
import { chromium } from 'playwright'
import { PNG } from 'pngjs'
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1280, height: 820 } })
try {
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'networkidle' })
await page.getByRole('heading', { name: /Product/i }).waitFor()
const canvas = page.locator('canvas').first()
await canvas.waitFor({ state: 'visible' })
const buffer = await canvas.screenshot()
const png = PNG.sync.read(buffer)
let colorfulPixels = 0
for (let index = 0; index < png.data.length; index += 4) {
const red = png.data[index]
const green = png.data[index + 1]
const blue = png.data[index + 2]
const alpha = png.data[index + 3]
if (alpha > 0 && Math.max(red, green, blue) - Math.min(red, green, blue) > 12) {
colorfulPixels += 1
}
}
assert.ok(colorfulPixels > 800, `expected nonblank canvas, got${colorfulPixels}`)
} finally {
await browser.close()
}
12. 배포 전 점검
npm run build
npm run lint
npm run qa:smoke
추가 체크:
Canvas wrapper가 desktop/mobile에서 충분한 크기인가
GLB 경로가 public/CDN 기준으로 맞는가
Suspense fallback이 빈 화면을 막는가
HTML 버튼으로도 모든 주요 조작이 가능한가
DPR과 shadow 비용이 모바일에서 과하지 않은가
직접 만든 texture/geometry/material cleanup이 있는가
13. 이 문서 이후 읽을 것
마지막 문서는 제품 커스터마이저를 직접 구현하는 통합 예제다.
04-product-configurator-workbook.md