티스토리 뷰
three.js OrbitControls·조명·그림자: 마우스로 돌려보는 교량 모델 [교량 BIM 바이브코딩 3편]
마구자바 2026. 10. 7. 13:52
2편에서는 박스 하나를 띄우면서 three.js의 기본 구조(장면·카메라·렌더러)를 익혔다. 그런데 화면을 마우스로 돌려볼 수 없으니, 교량처럼 큰 구조물은 한쪽 면밖에 볼 수 없었다.
3편에서는 OrbitControls로 마우스 회전·확대·이동을 붙이고, 조명 5가지와 그림자를 다뤄서 1편의 교량 모델을 구조해석 프로그램처럼 자유롭게 돌려볼 수 있게 만든다.
이번 편에서 만들 것
▲ 실제로 동작하는 데모. 마우스 왼쪽 드래그 회전, 휠 확대·축소, 오른쪽 드래그 이동. 아래 버튼으로 조명과 그림자를 켜고 끌 수 있다.
○ 버튼을 눌러 조명을 하나씩 켜고 꺼보면, 조명마다 교량이 어떻게 달라 보이는지 바로 확인할 수 있다.
○ 그림자를 끄면 교량이 땅에서 떠 보이고, 켜면 높이 관계가 한눈에 들어온다.
○ 자동 회전은 OrbitControls에 들어 있는 기능으로, 발표 화면이나 모델 소개용으로 쓰기 좋다.
OrbitControls란
○ 카메라가 중심점(target)을 기준으로 공전하도록 마우스·터치 입력을 처리해 주는 three.js 부가 기능이다.
○ 구조해석 프로그램에서 모델을 돌려보는 뷰 회전, 줌, 팬 기능을 코드 몇 줄로 붙일 수 있다.

| 조작 | 마우스 | 터치(스마트폰) | 하는 일 |
| 회전 (Rotate) | 왼쪽 버튼 드래그 | 한 손가락 드래그 | 카메라가 중심점 주위를 공전 |
| 확대·축소 (Zoom) | 휠 스크롤 | 두 손가락 벌리기·오므리기 | 중심점 쪽으로 다가가거나 멀어짐 |
| 이동 (Pan) | 오른쪽 버튼 드래그 | 두 손가락 드래그 | 카메라와 중심점을 함께 평행 이동 |
○ 주의할 점 : OrbitControls는 물체를 돌리는 것이 아니라 카메라를 움직이는 것이다. 그래서 2편처럼 box.rotation.y를 바꾸는 코드와는 전혀 별개로 동작한다.
OrbitControls 붙이는 3단계
① 불러오기
○ OrbitControls는 three.js 본체가 아니라 addons 폴더에 들어 있어서 따로 불러와야 한다. importmap에 three/addons/ 경로를 추가한다.
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
}
}
</script>
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
② 만들기
○ 카메라와 렌더러의 그림판(canvas)을 넘겨주면 끝이다. 중심점은 교량 중앙 높이로 잡는다.
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, pierH, 0); // 교량 중앙을 중심으로 회전
controls.update();
③ 매 프레임 갱신
○ 관성(enableDamping)을 켰다면 애니메이션 루프 안에서 controls.update()를 반드시 호출해야 한다. 빠뜨리면 관성이 동작하지 않는다.
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});
교량 모델에 쓰기 좋은 옵션
| 옵션 | 기본값 | 교량 예제 설정 | 효과 |
target |
(0, 0, 0) | (0, 7, 0) | 회전 중심. 교량 중앙(교각 상단 높이)으로 |
enableDamping |
false | true | 손을 떼도 부드럽게 멈춤 (관성) |
minDistance |
0 | 10 | 너무 가까이 들어가 모델 안으로 파고드는 것 방지 |
maxDistance |
무한대 | 150 | 너무 멀어져 모델을 잃어버리는 것 방지 |
maxPolarAngle |
π (180°) | π/2 − 0.05 | 카메라가 지면 아래로 내려가지 않게 |
enablePan |
true | true | false로 하면 이동 금지 (회전만) |
autoRotate |
false | 버튼으로 전환 | 중심점 주위를 자동 회전 (update 필요) |
○ maxPolarAngle은 위에서부터 잰 각도다. 0이면 바로 위(평면도), π/2면 수평, π면 바로 아래다. 지면이 있는 모델에서는 π/2보다 조금 작게 두면 땅 밑으로 카메라가 들어가지 않는다.
조명 5가지 비교
○ three.js의 주요 조명은 5가지다. 같은 교량을 조명 하나씩만 켜서 비교하면 다음과 같다.

| 조명 | 비유 | 그림자 | 교량 모델에서 쓰임 |
| AmbientLight | 흐린 날 전체 밝기 | X | 어두운 부분이 새까맣게 되지 않도록 바닥값만 깔아줌 |
| HemisphereLight | 하늘빛 + 땅 반사광 | X | 야외 구조물에 자연스러운 바탕 조명 (Ambient보다 추천) |
| DirectionalLight | 햇빛 (평행 광선) | O | 주 조명. 면마다 명암을 만들고 그림자를 드리움 |
| PointLight | 전구 | O | 야간 조명, 특정 부위 강조 |
| SpotLight | 손전등·무대 조명 | O | 점검 위치, 손상 부위 하이라이트 |
○ 구조물 모델에는 HemisphereLight(바탕) + DirectionalLight(주 조명, 그림자) 조합이 가장 무난하다. 위 데모의 기본 설정도 이 조합이다.
빛의 각도와 거리
면의 각도에 따라 밝기가 달라진다
○ DirectionalLight를 받는 면의 밝기는 빛이 면에 닿는 각도의 코사인(cos θ)에 비례한다. 빛을 정면으로 받는 면은 가장 밝고, 비스듬할수록 어두워진다.
○ 교량의 윗면, 옆면, 거더 하부가 서로 다른 밝기로 보이는 이유이고, 이 차이가 입체감을 만든다.
○ 그래서 DirectionalLight의 위치를 정면(카메라 쪽)에 두면 모든 면이 비슷하게 밝아져 오히려 납작해 보인다. 카메라와 옆으로 비껴서 위쪽에 두는 것이 좋다.
PointLight는 거리가 멀수록 급격히 어두워진다
○ PointLight와 SpotLight는 실제 빛처럼 거리의 제곱에 반비례해서 약해진다(decay = 2, 기본값). 거리가 2배가 되면 밝기는 1/4이 된다.
○ three.js 최신 버전은 물리적으로 정확한 단위를 쓰기 때문에, 교량처럼 수십 m 크기 모델에서는 PointLight 세기를 수백~수천 수준으로 크게 줘야 보인다. 예제에서는 new THREE.PointLight(0xffe2b0, 1200)을 썼다.
○ distance 값을 주면 그 거리에서 빛이 0이 되도록 잘라낸다. 조명이 많을 때 계산량을 줄이는 용도다.
| 증상 | 원인 | 해결 |
| PointLight를 넣었는데 안 보임 | 세기가 너무 작음 (예: 1) | 세기를 수백 이상으로, 또는 조명을 모델 가까이 |
| 조명 근처만 하얗게 타버림 | 세기가 너무 크거나 너무 가까움 | 세기를 줄이거나 위치를 멀리 |
| 예전 예제 코드가 너무 어두움 | 옛 버전 three.js 기준으로 쓴 세기 값 | 최신 버전 기준으로 세기 재조정 |
그림자 만들기 4단계
○ three.js의 그림자는 계산 비용이 커서 기본값이 꺼져 있다. 아래 4곳을 모두 켜야 그림자가 생긴다. 하나라도 빠지면 그림자가 안 나온다.
| 단계 | 코드 | 의미 |
| ① 렌더러 | renderer.shadowMap.enabled = true |
그림자 기능 전체 켜기 |
| ② 조명 | sun.castShadow = true |
이 빛이 그림자를 만든다 |
| ③ 물체 | mesh.castShadow = true |
이 물체가 그림자를 드리운다 (교량 부재) |
| ④ 바닥 | ground.receiveShadow = true |
이 물체 위에 그림자가 맺힌다 (지면) |

그림자 범위를 모델 크기에 맞추기
○ DirectionalLight의 그림자는 정해진 사각형 범위 안에서만 계산된다. 기본 범위는 ±5 정도라, 40 m 교량에서는 그림자가 잘려 나간다.

const s = sun.shadow.camera; // 그림자 계산 범위
s.left = -35; s.right = 35; // 교량 길이(40 m)보다 넉넉하게
s.top = 35; s.bottom = -35;
s.near = 1; s.far = 120;
○ 범위를 너무 크게 잡으면 같은 해상도로 넓은 면적을 덮어야 해서 그림자 가장자리가 계단처럼 거칠어진다. 모델보다 약간 크게가 적당하다.
그림자 해상도 (mapSize)
| mapSize | 품질 | 속도 | 추천 용도 |
| 512 × 512 (기본) | 거칠다 | 가장 빠름 | 작은 물체, 모바일 |
| 1024 × 1024 | 보통 | 빠름 | 일반 웹 데모 |
| 2048 × 2048 | 깔끔 | 보통 | 교량 한 경간 정도 (예제 설정) |
| 4096 × 4096 | 매우 깔끔 | 느림 | 고해상도 캡처용 |
전체 코드
○ 메모장에 붙여넣고 bridge3.html로 저장한 뒤 더블클릭하면 실행된다. ★ 표시가 이번 편에서 추가된 부분이다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>three.js 교량 - 마우스 조작과 조명</title>
<style>
body { margin: 0; overflow: hidden; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
}
}
</script>
</head>
<body>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// 1. 장면, 카메라, 렌더러
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdfe8f0);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 500);
camera.position.set(32, 18, 34);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true; // ★ 그림자 켜기 (1단계)
document.body.appendChild(renderer.domElement);
// 2. 치수 (단위: m)
const span = 40, width = 10, girderH = 1.8, pierH = 7;
// 3. 재료
const concrete = new THREE.MeshStandardMaterial({ color: 0xb8bcc2, roughness: 0.9 });
const steel = new THREE.MeshStandardMaterial({ color: 0x3f6e9e, metalness: 0.3, roughness: 0.5 });
const groundMat = new THREE.MeshStandardMaterial({ color: 0x9fb39a, roughness: 1 });
function box(w, h, d, mat, x, y, z) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
mesh.position.set(x, y, z);
mesh.castShadow = true; // ★ 그림자를 드리운다 (3단계)
mesh.receiveShadow = true;
scene.add(mesh);
return mesh;
}
// 4. 교량 부재
box(span, 0.25, width, concrete, 0, pierH + girderH + 0.125, 0); // 바닥판
for (const z of [-3.5, 0, 3.5]) box(span, girderH, 0.4, steel, 0, pierH + girderH / 2, z); // 거더
for (const x of [-span / 2 + 2, span / 2 - 2]) {
box(1.5, pierH, 1.5, concrete, x, pierH / 2, 0); // 교각
box(2.0, 0.8, width - 1, concrete, x, pierH - 0.4, 0); // 코핑
}
// 5. 지면 (그림자를 받는 바닥)
const ground = new THREE.Mesh(new THREE.PlaneGeometry(120, 120), groundMat);
ground.rotation.x = -Math.PI / 2; // 눕히기 (Y-up 좌표계)
ground.receiveShadow = true; // ★ 그림자를 받는다 (4단계)
scene.add(ground);
// 6. 조명
const hemi = new THREE.HemisphereLight(0xffffff, 0x6b7f63, 0.8); // 하늘색 + 땅색
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffffff, 2.2); // 햇빛
sun.position.set(25, 35, 15);
sun.castShadow = true; // ★ 이 빛이 그림자를 만든다 (2단계)
sun.shadow.mapSize.set(2048, 2048); // 그림자 해상도
const s = sun.shadow.camera; // 그림자 계산 범위를 교량 크기에 맞춤
s.left = -35; s.right = 35; s.top = 35; s.bottom = -35; s.near = 1; s.far = 120;
scene.add(sun);
// 7. 마우스 조작 (OrbitControls)
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, pierH, 0); // 교량 중앙을 중심으로 회전
controls.enableDamping = true; // 관성(부드럽게 멈춤)
controls.minDistance = 10; // 너무 가까이 못 가게
controls.maxDistance = 150; // 너무 멀리 못 가게
controls.maxPolarAngle = Math.PI / 2 - 0.05; // 땅 밑으로 못 내려가게
controls.update();
// 8. 화면 그리기
renderer.setAnimationLoop(() => {
controls.update(); // damping 사용 시 매 프레임 필요
renderer.render(scene, camera);
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
Claude에게 이렇게 요청했다
○ 1편 코드를 붙여넣고 아래처럼 요청하면 위 코드와 비슷한 결과가 나온다.
아래 three.js 교량 코드에 다음 기능을 추가해줘.
- OrbitControls로 마우스 회전·확대·이동. 회전 중심은 교량 중앙, 관성 켜기
- 카메라가 지면 아래로 내려가지 않게 제한
- 지면(초록색)을 추가하고 DirectionalLight로 그림자 표시
- 그림자 범위는 교량 전체(40 m)를 덮도록
- 바탕 조명은 HemisphereLight
- 추가하거나 바꾼 줄에는 ★ 표시와 한글 주석
(1편 코드 붙여넣기)
○ "바꾼 줄에 표시해줘"라고 요청하면 어디가 달라졌는지 바로 찾을 수 있다. 기존 코드를 고치는 요청에서 특히 유용하다.
자주 겪는 문제
| 증상 | 원인 | 해결 |
| 마우스로 돌려도 안 움직임 | OrbitControls 불러오기 실패 (콘솔에 import 오류) | importmap에 three/addons/ 경로 확인 |
| 관성이 안 먹음, 회전이 뚝뚝 끊김 | controls.update() 누락 |
애니메이션 루프 안에 update 추가 |
| 그림자가 아예 안 나옴 | 4단계 중 하나 누락 | renderer·조명·물체·바닥 네 곳 확인 |
| 그림자가 중간에 잘림 | 그림자 범위(shadow.camera)가 좁음 | left·right·top·bottom을 모델보다 크게 |
| 그림자에 줄무늬가 생김 (shadow acne) | 면이 자기 자신에게 그림자를 드리움 | sun.shadow.bias = -0.0005 정도로 조정 |
| 콘솔에 "PCFSoftShadowMap has been removed" 경고 | 인터넷의 옛 예제 코드를 그대로 씀 | 최신 버전에서 제거된 옵션. 그 줄을 지우면 기본 그림자(PCFShadowMap)가 쓰임 |
| 카메라가 땅 밑으로 들어감 | maxPolarAngle 미설정 | controls.maxPolarAngle = Math.PI / 2 - 0.05 |
정리
○ OrbitControls는 카메라를 움직이는 기능이다. target을 모델 중앙에 두고, update()를 매 프레임 호출한다.
○ 조명은 HemisphereLight + DirectionalLight 조합이 구조물 모델에 가장 무난하다.
○ 면의 밝기는 빛의 각도(cos θ)에, PointLight는 거리의 제곱에 반비례해서 달라진다.
○ 그림자는 렌더러·조명·물체·바닥 4곳을 켜고, 그림자 범위를 모델 크기에 맞춰야 제대로 나온다.
다음 편 예고
4편에서는 이번처럼 Claude에게 3D 모델을 요청할 때 원하는 결과를 한 번에 받는 프롬프트 작성법을 정리한다. 치수·좌표·단위를 설명하는 법, 기존 코드 수정을 요청하는 법, 결과가 틀렸을 때 다시 요청하는 법을 실제 예시로 다룬다.
관련 글
→ [교량 BIM 바이브코딩 1편] Claude 바이브코딩으로 3D 교량 만들기
→ [교량 BIM 바이브코딩 2편] three.js 기초: 장면·카메라·렌더러
→ [교량 BIM 바이브코딩 4편] AI 코딩 프롬프트 작성법: Claude에게 3D 모델 정확히 요청하기
→ [교량 BIM 바이브코딩 5편] three.js ExtrudeGeometry: I형 거더 단면 3D 모델링
→ [교량 BIM 바이브코딩 6편] three.js 파라메트릭 모델링: 슬라이더로 바뀌는 교량
→ [교량 BIM 바이브코딩 7편] three.js 교량 BIM 모델: 클릭하면 속성이 나오는 부재 조립
→ [교량 BIM 바이브코딩 8편] three.js 곡선교 모델링: 곡선 반경 R과 편경사로 휘는 교량
→ 토목 엔지니어가 자바스크립트와 Three.js를 배워야 하는 이유
'엔지니어링 코딩 > three.js·AI 코딩' 카테고리의 다른 글
| AI 코딩 프롬프트 작성법: Claude에게 3D 모델 정확히 요청하기 [교량 BIM 바이브코딩 4편] (0) | 2026.10.07 |
|---|---|
| three.js ExtrudeGeometry: I형 거더 단면 3D 모델링 [교량 BIM 바이브코딩 5편] (0) | 2026.10.07 |
| three.js 기초: Claude로 배우는 장면·카메라·렌더러 [교량 BIM 바이브코딩 2편] (0) | 2026.10.06 |
| 토목 엔지니어가 자바스크립트와 Three.js를 배워야 하는 이유 (0) | 2026.10.05 |
| three.js 입문: Claude 바이브코딩으로 3D 교량 만들기 [교량 BIM 바이브코딩 1편] (0) | 2026.10.05 |
