R3F로 3D Room 띄우기
September 24, 2025
블렌더에서 간단하게 room 모델링을 하고 glb 파일로 내보낸다.
R3F 설정
R3F(React Three Fiber)는 Three.js를 React에서 선언적으로 사용할 수 있게 해주는 라이브러리다.
pnpm add three @react-three/fiber @react-three/drei
Canvas 컴포넌트 안에서 Three.js 씬을 구성하고, JSX 문법으로 mesh, light 등을 선언할 수 있다.
export default function Room() {
return (
<Canvas
shadows={{ type: THREE.PCFSoftShadowMap }}
camera={{ position: [20, 20, 20], fov: 20 }}
gl={{
antialias: true,
powerPreference: "high-performance",
}}
>
<Scene />
</Canvas>
);
}
GLB 모델 로드
블렌더에서 만든 3D 모델을 GLB 포맷으로 export해서 public/models/ 폴더에 넣었다. R3F에서는 @react-three/drei의 useGLTF 훅으로 GLB 파일을 불러올 수 있다.
const Scene = () => {
const { scene } = useGLTF("/models/room.glb");,,
return <primitive object={scene} />;
};
useGLTF.preload("/models/room.glb");
useGLTF.preload는 컴포넌트가 마운트되기 전에 미리 GLB 파일을 로드해서 로딩 시간을 줄여준다.
머티리얼 커스터마이징

서랍 위 화분 앞에 있는 캔들은 반투명한 재질을 생각하고 모델링 했다. GLB 포맷은 기본적인 머티리얼 정보를 담을 수 있지만, 블렌더의 Principled BSDF에서 설정한 Transmission 같은 물리 기반 재질은 완벽하게 변환되지 않는다. Three.js와 블렌더의 쉐이더 방식이 다르기 때문이다. 그래서 유리 재질 촛대는 Three.js의 MeshPhysicalMaterial로 직접 구현했다.
const glassMaterial = new THREE.MeshPhysicalMaterial({
transmission: 0.5,
roughness: 0.1,
ior: 1.5,
thickness: 0.02,
clearcoat: 1,
});
scene.traverse로 mesh를 순회하면서 mesh 이름으로 찾아 머티리얼을 교체했다.
useEffect(() => {
scene.traverse((child: any) => {
if (child.isMesh) {
const name = child.name.toLowerCase();
if (name === "candle") {
child.material = glassMaterial;
}
if (name.includes("candle001")) {
child.material = waxMaterial;
}
}
});
}, [scene]);
밤낮 조명 전환

useState로 isNight 상태를 관리하고, 조명 컴포넌트에 prop으로 넘겨서 밤낮 조명을 전환했다.
const [isNight, setIsNight] = useState(false);
<Light isNight={isNight} />
낮에는 ambientLight와 directionalLight로 자연광을 표현하고, 밤에는 스탠드 조명 위치에 spotLight와 pointLight를 배치해서 실내 조명을 표현했다.
const Light = ({ isNight }: { isNight: boolean }) => {
return (
<>
{isNight ? (
<>
<ambientLight intensity={1} color={0x222244} />
<spotLight
color="#ffffff"
intensity={80}
position={[-2.3, 5.5, 0.1]}
angle={THREE.MathUtils.degToRad(30)}
penumbra={1}
/>
</>
) : (
<>
<ambientLight intensity={2} color={0xffffff} />
<directionalLight position={[3, 10, -5]} intensity={15} castShadow />
</>
)}
</>
);
};
조명이 켜진 것처럼 보이게 하기 위해 스탠드 mesh에 emissive를 적용했다. isNight가 바뀔 때마다 emissiveIntensity를 업데이트하도록 useEffect 의존성 배열에 추가했다.
if (name.includes("lamp1") || name.includes("cylinder002")) {
child.material = child.material.clone();
child.material.emissive = new THREE.Color("#ffffff");
child.material.emissiveIntensity = isNight ? 2 : 0;
}
.clone()을 하지 않으면 같은 머티리얼을 공유하는 다른 mesh도 함께 변경되기 때문에 반드시 복제해야 한다.
Shadow Acne 해결
조명을 추가하고 나서 mesh 표면에 줄무늬 노이즈가 생기는 Shadow Acne 현상이 발생했다. 이는 그림자 계산 시 mesh가 자기 자신에게 그림자를 드리우면서 생기는 문제다.
shadow-bias 값을 조정해서 해결했다.
<pointLight
castShadow
shadow-bias={-0.005}
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
shadow-bias 값은 -0.001 ~ -0.01 사이에서 노이즈가 사라지는 값을 찾으면 된다. 값이 너무 크면 그림자가 mesh에서 떠 보이는 Peter Panning 현상이 생기므로 적절히 조절해야 한다.
GLSL 쉐이더로 연기 효과

촛불 위에 피어오르는 연기 효과를 GLSL 쉐이더로 구현했다. PlaneGeometry에 커스텀 ShaderMaterial을 적용하고, Perlin noise 텍스처를 활용해 연기처럼 보이게 했다.
const smokeMaterial = useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader: smokeVertexShader,
fragmentShader: smokeFragmentShader,
uniforms: {
uTime: new THREE.Uniform(0),
uPerlinTexture: new THREE.Uniform(perlinTexture),
},
side: THREE.DoubleSide,
transparent: true,
depthWrite: false,
}),
[perlinTexture],
);
vertex 쉐이더에서는 연기가 올라가면서 비틀리고 바람에 흔들리는 효과를 구현했다. Perlin noise로 비틀림 각도를 계산하고, UV의 y값이 높을수록 바람의 영향을 더 많이 받도록 pow(vUv.y, 2.0)으로 가중치를 줬다. 연기 위쪽이 더 많이 흔들리는 자연스러운 효과가 만들어진다.
float twistPerlin = texture2D(
uPerlinTexture,
vec2(0.5, vUv.y * 0.2 - uTime * 0.01)
).r;
float angle = twistPerlin * 3.0;
newPosition.xz = rotate2D(newPosition.xz, angle);
windOffset *= pow(vUv.y, 2.0) * 1.5;
newPosition.xz += windOffset;
fragment 쉐이더에서는 Perlin noise 텍스처를 UV 좌표로 샘플링하고, uTime으로 UV를 위쪽으로 이동시켜 연기가 올라오는 효과를 만들었다. 가장자리가 자연스럽게 사라지도록 smoothstep으로 상하좌우 경계를 페이드 아웃 처리했다.
smokeUv.y -= uTime * 0.04;
float smoke = texture(uPerlinTexture, smokeUv).r;
smoke = smoothstep(0.4, 1.0, smoke);
smoke *= smoothstep(0.0, 0.1, vUv.x);
smoke *= smoothstep(1.0, 0.9, vUv.x);
smoke *= smoothstep(0.0, 0.1, vUv.y);
smoke *= smoothstep(1.0, 0.4, vUv.y);
useFrame으로 매 프레임마다 uTime을 업데이트해서 연기가 계속 올라오는 애니메이션을 구현했다.
useFrame(({ clock }) => {
smokeMaterial.uniforms.uTime.value = clock.elapsedTime;
});