logo

DowanKim

2. 웹 버전 캐드?블렌더?를 만들어보자

2026년 2월 6일

SIMVEX: 공학 학습용 웹 기반 3D 기계 부품 뷰어

저는 이번 프로젝트에서 뷰어페이지, 즉 3D 환경에 대한 모든 부분 구현을 맡았습니다.

사실 나름 디자인과 출신이기도 해서 3D환경에서 어떤 기능이 필요한지 익숙하기도 해서 프론트엔드 개발자로서 해보고 싶다고 생각이 들었지만..

처음에 보고는 음 저걸 어떻게 만들지 라는 생각도 했습니다. 하지만 저희에게는 2주도 있지않는 시간. 멍때릴 시간에 빨리 시작하는게 급선무 입니다.

이전에 툴 선택에서, R3F와 Three.js를 같이 사용하기로 했었으니, 이를 기반으로 하나하나 구현해 봅시다.


1. 3D 씬 기본 구성 (Canvas & 톤매핑)

먼저 Canvas를 설정합니다. 카메라 near/far, DPR, 톤매핑 등을 지정해 화질과 심도를 확보했습니다.

톤매핑이 뭐냐고 물으신다면, 음.. 밝은 부분은 날라가지 않고 어두운 부분은 너무 안보이지 않게 보이는 밝기의 범위를 좁힌다라고 생각하시면 될 것 같습니다.

<Canvas camera={{ position: [0, 5, 15], fov: 50, near: 0.01, far: 10000 }} dpr={[1, 2]} shadows gl={{ antialias: true, toneMapping: THREE.ACESFilmicToneMapping, outputColorSpace: THREE.SRGBColorSpace, }} > <Suspense fallback={null}> <SceneContent ... /> </Suspense> </Canvas>

2. GLTF 구조는 어떻게 받아오고, 어떻게 쪼갰나

GLTF를 받아오면 scene 전체를 복제해서 선택 가능한 노드를 직접 마킹합니다.
여기서 중요한 포인트는 userData에 nodeId, nodeName, selectable을 붙이는 것입니다.

const clonedScene = scene.clone(true); clonedScene.traverse((child) => { const hasName = child.name && child.name.trim() !== ''; const name = child.name?.trim() || ''; if (name.startsWith('Solid')) { child.userData.selectable = false; return; } const hasChildren = child.children.length > 0; const isSelectablePart = hasName && hasChildren && !name.startsWith('Solid'); if (isSelectablePart) { const nodeId = `node_${nodeIdCounter++}`; child.userData.modelRef = modelRef.current; child.userData.nodeId = nodeId; child.userData.nodeName = child.name || nodeId; child.userData.selectable = true; child.traverse((descendant) => { if (descendant instanceof THREE.Mesh || descendant instanceof THREE.Group || descendant instanceof THREE.Object3D) { if (!descendant.userData.modelRef) { descendant.userData.modelRef = modelRef.current; } if (!descendant.userData.nodeId) { descendant.userData.nodeId = nodeId; } if (!descendant.userData.nodeName) { descendant.userData.nodeName = child.name || nodeId; } descendant.userData.selectable = true; } }); } else { child.userData.selectable = false; } });

이제 우리는 백엔드에서 gltf파일을 받아오는데, gltf파일 열어보면 한 씬 안에 굉장히 많은 노드가 들어 있습니다. 근데, 그냥 모든 노드를 화면에 처음 다 띄었다가 아주 그냥 컴퓨터 터질려 하고 난리도 아니었습니다.

노드 중에는 진짜 부품도 있고, 단순히 그루핑용, 정리용, 아니면 재질용 등 의 노드도 많았습니다..

그럼 gltf파일을 받아왔을 때, 프론트에서 이제 전처리단계가 있어야 3d 씬에 부품들을 띄울 수 있겠죠.

판단기준은 자체적으로 잡았습니다.

1.이름이있어야함 2.자식이 있어야함 3.Solid로 시작하면 제외

이 구조 덕분에 클릭 → 노드 탐색 → nodeId 기반 선택이 가능합니다. 즉 부품마다 개별 선택이 가능해집니다.


3. “조립/분해”만으로는 구조 파악이 어렵다

학습용으로 쓰려면 단순히 분해/조립만으로는 구조 파악이 어렵다고 느꼈습니다.

물론 이제 대회측에서 원한거는 분해 조립 슬라이드바였지만.. 사실 그것만으로는 안에 겹치기도 하고 뭔가 사용하기도 불편하고, 그래서 분해 조립의 방법을 좀 애니메이션이 있고 막 나사가 있으면 나사가 그 모양대로 풀리고 이런걸 하고 싶지만, 우리에게 시간이 너무 없습니다.

그래서 과거 블렌더를 사용했던 기억을 떠올려 아래 3가지 모드를 추가했습니다.

  • 1번: 일반 조명 + 일반 렌더

image.png

  • 2번: 낮은 조명 + 일반 렌더

image.png

  • 3번: 낮은 조명 + 와이어프레임

image.png

if (event.key === '1') { setViewMode('lit'); setRenderMode('normal'); } else if (event.key === '2') { setViewMode('dim'); setRenderMode('normal'); } else if (event.key === '3') { setViewMode('wireframe'); setRenderMode('wireframe'); }

결과적으로 형태, 구조, 내부 관계를 훨씬 잘 파악할 수 있습니다.


4. “광택 셰이더 + 렌더 품질”

GLTF 기본 재질은 모델마다 다르기 때문에 일관된 광택감을 보정했습니다.

modelRef.current.traverse((child) => { if (child instanceof THREE.Mesh) { child.castShadow = true; child.receiveShadow = true; const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((material) => { if (material instanceof THREE.MeshStandardMaterial) { material.metalness = Math.max(material.metalness ?? 0, 0.2); material.roughness = Math.min(material.roughness ?? 1, 0.4); material.envMapIntensity = Math.max(material.envMapIntensity ?? 0.8, 0.8); material.needsUpdate = true; } }); } });

6. 조명 설계 — 밝기 2단계, 광택 강화

조명은 기본 상태와 dim 상태를 분리해 줍니다.

대회측에서 어느정도의 조명과 광택질감을 원하기도 했고, 부품마다의 특징을 잘 살리기 위해서는 그러한 렌더링이 필요했습니다.

물론 이걸 원하지 않는 사람도 있기에, 앞선 단계에서 1,2 버튼으로 조명 유무를 정할 수 있게 해 두었습니다.

const isDim = mode === 'dim'; const ambientIntensity = isDim ? 0.15 : 0.4; const hemiIntensity = isDim ? 0.12 : 0.25; const keyIntensity = isDim ? 0.45 : 0.8; const fillIntensity = isDim ? 0.2 : 0.35; const envIntensity = isDim ? 0.35 : 0.7; <ambientLight intensity={ambientIntensity} /> <hemisphereLight intensity={hemiIntensity} ... /> <directionalLight intensity={keyIntensity} ... /> <directionalLight intensity={fillIntensity} ... /> <Environment preset="city" environmentIntensity={envIntensity} />

7. 선택 강조 (테두리)

학습용 뷰어에서 “내가 선택한 게 뭔지 확실히 보여주는 것”은 굉장히 중요합니다.
그래서 EdgesGeometry로 라인을 추가해 선택 상태를 강조했습니다. 내가 선택한 객체를 더욱 뚜렷하게 잘 보여주는 효과를 가져다 주었습니다.

const edges = new THREE.EdgesGeometry(child.geometry, 40); const material = new THREE.LineBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.9, depthTest: false, }); const outline = new THREE.LineSegments(edges, material); outline.scale.set(1.01, 1.01, 1.01); child.add(outline);

8. 조립/분해 로직 — 학습용 기준의 “분해 방향”

슬라이더 값에 따라 부품이 원점에서 바깥으로 이동합니다.
직관적으로 구조를 파악할 수 있도록 했습니다.

image.png

const factor = value / 100; const basePosition = isUserModified ? node.userData.userModifiedPosition.clone() : initialPosition.clone(); const localDirection = direction.clone().applyQuaternion(parentRotation.clone().invert()); const offset = localDirection.multiplyScalar(distance * factor); const newLocalPosition = basePosition.clone().add(offset); node.position.copy(newLocalPosition);

학습 환경에서는 “조립/분해”만으로는 구조를 이해하기 어렵습니다.
렌더 모드를 전환할 수 있는 환경이 훨씬 직관적이고,
특히 공대생/학습 목적 사용자에게는 구조 인지 + 공간 파악이 더 중요하다고 판단되었습니다.

이런 기준으로 설계했기 때문에, 단순 3D 뷰어가 아니라
학습용 구조 분석 도구에 가까운 경험을 제공할 수 있었습니다.