Blender에서 만든 캐릭터가 Three.js에서 망가졌다 — 3D 캐릭터 구현 회고
funda 프로젝트와 개인 포트폴리오에 Three.js로 캐릭터를 올리며 겪은 문제들을 적은 기록
Comments
The password you enter is used to open, edit, and delete secret comments.
funda 프로젝트와 개인 포트폴리오에 Three.js로 캐릭터를 올리며 겪은 문제들을 적은 기록
The password you enter is used to open, edit, and delete secret comments.
왜 캐릭터가 필요했을까요?
모델링부터 리깅까지
매쉬를 부드럽게 해주는 subdivision이 적용되지 않아 로우 폴리로 렌더링된 모습
가짜 하이폴리로 만드는 쉐이딩의 마법
굴절 대신 텍스처 리터칭
Draw Call 감소시키기
TL;DR
엔진의 한계 이해: Blender에서 완벽한 구조가 Three.js에서도 정답은 아닙니다.
결과 중심의 리깅: 리깅은 단순히 "가져오는 것"이 아니라 엔진용 "결과 데이터로 변환"하는 과정입니다.
설계의 중심: 모든 판단 기준은 "웹 런타임에서 어떻게 효율적으로 동작하는가"에 두어야 합니다.
이번 아티클에 등장하는 두 프로젝트의 포트폴리오는 Funda, Chaen에서 보실 수 있습니다.
FUNDA 프로젝트를 진행하며 서비스의 핵심 가치인 '재미'와 '게이미피케이션'을 시각적으로 투영할 매개체가 필요했습니다. 단순히 화면을 꾸미는 장식이 아니라, 사용자와 정서적으로 교감하며 학습 동기를 부여하는 UX 구성 요소로서 캐릭터를 기획하게 되었습니다.
캐릭터는 세 가지 기준으로 선택하였습니다.
처음엔 서비스 '펀다' 와 이름이 비슷한 판다를 생각해봤습니다. 판다는 귀여움을 어필하긴 좋았지만 '지능적인' '학습' 카테고리보단 '여유로움' 에 맞는 것 같아 보류해 두었습니다.
다음으로 수달도 생각해 보았는데, 아무래도 인간형에 가까운 외형이 애니메이션을 고민하기 더 좋을 것 같아서 후보에 남겨뒀습니다.
마지막으로 여우는 똑똑한 이미지, 개발자, 학습과 모두 잘 어울렸지만 주황색이 브랜드 컬러와 충돌이 나 고민을 하게 되었는데, 결론적으로 같은 여우과인 북극 여우를 최종적으로 선택하게 되었습니다.
![]() | ![]() | ![]() |
|---|
본격적인 3D 작업에 앞서, 캐릭터의 인상과 동세를 결정짓기 위해 간단한 스케치와 2D 구현을 선행했습니다. 사실 2D 결과물만으로도 서비스 운영 자체에는 큰 지표적 문제가 없어 보였으나, 퀄리티 면에서 한 끗이 아쉽다는 피드백을 받았습니다.
당시 작업 시간(Resource)과 결과물의 퀄리티 사이에서 많은 고민이 있었지만, 이번 프로젝트의 키 포인트은 결국 캐릭터라고 판단했습니다. 프로젝트의 성패를 가를 중요한 요소라면 시간을 더 쏟더라도 확실한 퀄리티를 확보하는 것이 맞다고 생각했고, 그렇게 3D 캐릭터를 향한 도전이 시작되었습니다.
면을 무작정 늘리는 대신 Weighted Normal과 High Poly Normal Map Baking 전략을 사용했습니다.
이전처럼 안구 위에 유리막을 씌우는 방식은 너무 무거웠습니다. 이번에는 눈알 메쉬를 하나로 합치고 디테일을 텍스처로 구워버리는 방식을 택했습니다.
굴절 연산이 빠진 자리는 포토샵에서 직접 하이라이트와 뎁스감을 그려 넣어 보완했습니다.
렌더링 부하는 줄이면서 시각적 만족도는 챙기기 위한 선택이었습니다.
사람 캐릭터를 두 번 구현해 본 경험을 바탕으로 진행했습니다. 데포르메를 여러 차례 수정하며 디테일을 잡았습니다. 대부분 노이즈를 사용하여 텍스처링 했으나 디테일이 필요한 부분은 Poly Haven에서 가져와 사용했습니다.
| 1차 헤드 | 2차 데포 | 3차 데포 |
|---|---|---|
![]() | ![]() | ![]() |
특히 가장 공을 들인 부분은 눈알이었습니다. 북극 여우 특유의 신비로움을 위해 보라색과 흰색을 조합하고, Transmission과 Glass BSDF를 활용해 유리 같은 굴절 효과를 구현했습니다. 하지만 이러한 노드 구조가 추후 Three.js 환경에서 큰 기술적 도전 과제가 되었습니다. (후술)

리깅은 동물형 헤드 + 인간형 바디 구조를 기반으로 Rigify를 활용했습니다. 목도리와 꼬리에 찰랑거림을 주기 위해 댐프 트랙(Damped Track)도 적용했습니다.
얼굴 표정은 얼굴 메쉬, 속눈썹, 눈썹, 혓바닥, 이빨에 각각 Shape Key를 부여해 컨트롤했습니다.
| body rigging | facial expression |
|---|---|
![]() | ![]() |
Blender 안에서는 완벽하게 작동했고 추출만 하면 되는 상황이었습니다. 문제는 그 다음에서 발생했습니다.
웹에 올려보니 모디파이어가 전혀 적용되지 않은 상태로 렌더링 됐습니다. Three.js는 매 프레임 모디파이어를 실시간 계산하는 구조를 지원하지 않습니다. Blender로 돌아가서 모디파이어를 apply 해야 했는데, Shape Key가 있는 메쉬는 Subdivision Modifier를 정상적으로 apply할 수 없다는 오류가 발생했습니다.
![]() | ![]() |
|---|

이미 Shape Key를 5번 이상 다시 만든 상태라 다시 처음부터 하기엔 시간이 부족했고, 결국 외부 플러그인(SKKeeper)으로 강제 적용하게 되었습니다. shape key가 사라졌다는 에러 문구가 다수 발생하여 망가지면 다시 구현할 생각을 하며 threejs로 옮겼는데 화면상 문제는 없어 보였고 modifier도 잘 적용이 되었습니다. 하지만 정석적인 방법이 아니라 찝찝함은 남았습니다.
Rigify 기반의 IK, Constraint 구조가 Three.js에서는 완전히 무시됐습니다. 의도한 움직임이 하나도 구현되지 않았습니다. deform 본만 내보냈을 땐 당연한 결과였지만, 다른 MCH, ORG, DEF 본을 다 내보내서 인식하고 컨트롤하려고 했을 때도 전혀 움직여지지 않았습니다.
Blender에서는 Controller를 통해 실시간으로 본을 컨트롤할 수 있었습니다. 반면 Three.js에서는 컨트롤러 자체가 아예 무시됐습니다.
Rigify 본을 가져와서 직접 컨트롤하려고 했고 의도대로 동작하지 않았습니다. 여러 차례 다시 본을 추출해보다 시간이 오래 걸려 다섯 개의 스크립트를 짜 순서대로 디버깅 해보았습니다.
결과: 본은 다 있고, 매핑도 됐고, 좌표값도 움직이는데, 화면에서 반영이 안 됐습니다.
결론적으로, Rigify는 구조가 너무 복잡해서 Three.js에서 직접 컨트롤할 수 없다는 사실을 알게 되었습니다. 오로지 Rigify와 직접적인 연결이 없고 head bone에 parenting한 눈알, 눈썹, 혓바닥 같은 요소들만 움직일 수 있었습니다.
캐릭터의 생동감을 결정짓는 '눈'을 구현할 때, Blender에서의 정교한 노드 설계가 웹 엔진(Three.js)에서는 의도대로 작동하지 않는 기술적 병목을 마주했습니다.
하지만 GLB로 export하여 Three.js에서 확인했을 때, 블렌더에서의 의도는 완전히 무너졌습니다.
이미지 베이킹만으로는 웹에서 물리적인 유리 질감을 재현할 수 없다고 판단하여, 런타임에서 직접 MeshPhysicalMaterial을 주입하는 방식을 선택했습니다.
MeshPhysicalMaterial은 Three.js에서 가장 물리적으로 정확한 재질 표현을 지원합니다. IOR(굴절률), Transmission, Coat 등의 파라미터를 직접 세팅해 Blender의 Glass 재질과 유사한 결과를 런타임에서 재현할 수 있었습니다. 완전히 동일하진 않지만, 의도한 신비로운 눈 표현은 충분히 구현되었습니다.
| before | after |
|---|---|
![]() | ![]() |
Constraint 기반 리깅을 제거하고, 모든 애니메이션을 프레임 단위 키프레임 데이터로 베이크했습니다.
애니메이션은 Dope Sheet와 Action Editor에서 각 동작을 정리하고, 이를 NLA(Non-Linear Animation) Editor로 푸시(Push Down)하여 트랙을 관리했습니다. 엔진에서 특정 애니메이션을 트리거하기 위해선 클립 이름이 명확해야 하기에, 추출 전 모든 액션 이름을 정제하여 구웠습니다.
덕분에 코드 단에서 actions['Greeting'].play()와 같이 혼선 없이 애니메이션을 제어할 수 있었습니다.
이 두 가지를 분리하고 나서야 인터랙션이 가능해졌습니다.
useFixSkinnedMesh 커스텀 훅Subdivision 강제 적용 이후 Three.js에서 새로운 문제들이 생겼습니다.
이를 해결하기 위해 커스텀 훅을 만들었습니다.
프로젝트를 진행하며 캐릭터의 시선 추적(Eye Tracking)과 클릭 시 넘어지는 애니메이션 등을 추가했습니다. 여기서 가장 고민했던 지점은 **'어디까지 베이크(Bake)할 것인가'**였습니다.
모든 움직임을 블렌더에서 베이크해서 가져오면 구현은 훨씬 간편합니다. 하지만 그렇게 하면 **실시간 인터랙션이 불가능한, 단순히 '재생 버튼만 누르는 GIF'**와 다를 바 없다고 판단했습니다. 제가 원했던 것은 다양한 표정 조합을 상황에 맞춰 유연하게 돌려쓰는 것이었습니다.
따라서 다음과 같은 하이브리드 전략을 세웠습니다.
이후 별도의 컨트롤러 컴포넌트와 캐릭터 체험 페이지를 구축하여, 사용자가 직접 컨트롤러를 조작하며 캐릭터의 다양한 애니메이션과 표정 변화를 실시간으로 경험할 수 있도록 구현했습니다.
링크 정보를 불러오는 중...
하이브리드 방식을 채택하면서 서로 다른 제어 주체(애니메이션 데이터 vs 실시간 마우스 좌표)가 충돌하는 문제가 발생했고, 이를 해결하기 위해 두 가지 예외 처리를 적용했습니다.
특정 애니메이션(예: 넘어지기, 인사하기)이 재생되는 동안에도 시선이 마우스를 따라가게 되면, 애니메이션 데이터가 정의한 본(Bone)의 회전값과 실시간 lookAt 로직이 충돌하여 화면이 떨리거나 부자연스러운 움직임이 발생합니다. 이를 방지하기 위해 액티브 애니메이션이 실행되는 동안에는 시선 추적 로직을 일시적으로 차단하는 플래그를 설계했습니다.
표정은 중첩될 수 있습니다. 예를 들어 '웃는 표정'의 가중치가 1인 상태에서 '화난 표정'을 1로 바꾸면 메쉬가 기괴하게 뒤틀릴 수 있습니다. 이를 막기 위해 새로운 표정 그룹으로 전환될 때 이전 그룹의 Shape Key 인플루언스(Influence) 값을 0으로 정밀하게 초기화한 후 새로운 값을 주입하는 로직을 구축했습니다.
다수의 페이지(배틀, 로그인, 결과 화면 등)에 3D 캐릭터가 배치되는 구조였기 때문에 모바일 성능이 중요했습니다. 저사양 기기에서의 프레임 드랍(FPS 저하)을 막기 위해 여러 방면으로 접근했습니다.
requestAnimationFrameThree.js의 기본 렌더 루프를 requestAnimationFrame 기반으로 구성했습다. 브라우저가 다음 프레임을 그리기 직전에 콜백을 실행하는 방식이라, 모니터 주사율(60Hz, 120Hz 등)에 자연스럽게 맞춰집니다. 탭이 백그라운드로 이동하면 자동으로 일시정지돼 불필요한 연산을 줄여줍니다.
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))최신 모바일 기기는 픽셀 밀도(DPR)가 3~4에 달하는 경우도 있습니다. DPR이 높을수록 GPU가 처리해야 할 픽셀 수가 제곱으로 늘어납니다. DPR을 최대 2로 제한해 시각적으로는 큰 차이 없이 성능을 확보했습니다.
antialias: false안티앨리어싱은 계단 현상을 줄여주는 기능이지만, GPU에 상당한 부하를 줍니다. 모바일 환경에서는 DPR 자체가 이미 높기 때문에 계단 현상이 덜 눈에 띕니다. 비활성화해도 체감 품질 차이가 크지 않아 과감히 껐습니다.
Blender에서 작업하다 보면 [Action Stash] 같은 임시 애니메이션 트랙이 NLA Editor에 쌓입니다. 이게 GLB로 export될 때 같이 포함되면 파일 용량이 불필요하게 커지고, Three.js가 로드 시 모든 트랙을 파싱하면서 메모리도 낭비됩니다. 따라서 사용하지 않는 트랙은 전부 삭제하고 export했습니다.
Blender의 Principled BSDF, Noise, Gradient 같은 절차적(Procedural) 셰이더는 Three.js에서 실시간으로 계산할 수 없습니다. GLB export 전에 이미지 텍스처로 베이킹(Baking)해두면, Three.js는 단순히 텍스처 이미지를 입혀주기만 하면 됩니다. 실시간 조명 및 셰이더 연산 비용을 크게 줄여줍니다.
베이킹 대상: Color, Roughness, Normal, Alpha 맵
SkeletonUtils.clone같은 캐릭터를 여러 페이지(배틀, 결과 화면 등)에서 사용하기 때문에, 매번 새로 로드하지 않고 인스턴싱 방식으로 재사용했습니다. 단순히 scene.clone()을 쓰면 본(Bone) 구조가 꼬여 애니메이션이 모든 인스턴스에 중복 적용되는 문제가 발생합니다.
이를 해결하기 위해 SkeletonUtils.clone을 사용하여 지오메트리는 공유하되 스켈레톤만 독립적으로 복제하는 구조를 구현했습니다.
조명 세팅과 카메라 컨트롤을 매 페이지마다 새로 구성하지 않고, 하나의 씬 컴포넌트로 만들어 재사용했습니다. React 구조상 동일한 컴포넌트는 메모리를 공유하기 때문에 중복 렌더링 비용을 줄일 수 있었습니다.
메쉬 자체가 고폴리곤이라 런타임 최적화엔 한계가 있었고, Blender 단계에서 추가로 손볼 여지가 많이 남아 있었습니다. 특히 LOD(Level of Detail) 를 적용하면 카메라와의 거리에 따라 폴리곤 수를 자동으로 줄여주는데, 이걸 도입했다면 모바일 성능을 더 끌어올릴 수 있었을 것입니다. 다음 프로젝트에서 꼭 적용해보고 싶은 부분이었습니다.

첫 번째 캐릭터 '펀디'에서 겪은 성능 병목은 저에게 큰 교훈을 주었습니다. 웹 환경에서의 3D는 단순히 '예쁜 모델'이 아니라 '철저히 계산된 에셋' 이어야 한다는 점입니다. 이번 사람 캐릭터 프로젝트에서는 설계 단계부터 다음의 최적화 파이프라인을 적용했습니다.
과거에는 파츠별로 수십 장의 이미지를 사용해 엔진에 부하를 주었습니다. 이번에는 얼굴 + 몸통, 옷 파츠, 악세서리, 인테리어 등 유사한 재질끼리 묶어 2048px 사이즈의 아틀라스 3장으로 통합했습니다.
PBR 워크플로우에서 필요한 Occlusion, Roughness, Metalness는 각각 별도의 텍스처가 필요합니다. 하지만 텍스처 3장은 곧 3배의 GPU 메모리와 네트워크 전송량을 의미하죠.
이 세 데이터는 모두 0에서 1 사이의 값을 가지는 그레이스케일 데이터라는 점에 착안해, 하나의 이미지 속 R(AO), G(Roughness), B(Metallic) 채널에 각각 패킹했습니다.

별도의 툴을 쓰기보다 파이프라인의 자동화를 위해 직접 Node.js와 sharp 라이브러리를 활용해 스크립트를 짜서 해결했는데, 이게 생각보다 훨씬 효율적이었습니다.
sharp를 이용하면 수십 개의 텍스처를 일괄적으로 처리할 수 있습니다. 특히 헤어처럼 알파 채널이 필요한 경우 ORMA로 확장해서 관리할 수도 있습니다.
색상 변경이 잦은 머리카락과 외투는 흑백으로 베이킹한 뒤, Three.js 런타임에서 material.color.set()으로 직접 주입하여 메모리를 절약하고 커스터마이징 유연성을 확보했습니다.
| 흑백 | 컬러매핑 |
|---|---|
![]() | ![]() |
사용자의 이탈을 막기 위해 Blender에서 Draco 압축으로 내보내고 Threejs에서 해제해서 받는 방식을 사용하여 모델 용량을 80% 이상 절감하고 네트워크 로딩 시간을 획기적으로 단축했습니다.
이번 작업에서 얻은 결론은, 3D 캐릭터 제작은 모델링 문제가 아니라, 실행 환경에 맞춘 구조 설계 문제라는 것입니다.
Blender 안에서 완벽하게 작동하는 구조가 Three.js에서는 아무 의미가 없을 수 있습니다. 반대로 Blender에서 조금 불편하더라도, 엔진에서 제어 가능한 방식으로 설계하면 결과가 훨씬 좋아집니다. 첫 번째 캐릭터에서 삽질한 덕분에 두 번째 캐릭터는 처음부터 제대로 만들 수 있었습니다. 틀린 방향으로 열심히 해본 경험이 결국 올바른 설계 기준을 만들어줬습니다.
=== 🔍 DEBUG 1: 본 존재 여부 확인 ===
debug1.tsx:45 ✅ hand_ik.L: {type: 'Bone', isBone: true, rotation: 'x:1.64 y:-0.03 z:-1.66'}
debug1.tsx:45 ✅ hand_ik.R: {type: 'Bone', isBone: true, rotation: 'x:1.64 y:0.03 z:1.66'}
debug1.tsx:45 ✅ hips: {type: 'Bone', isBone: true, rotation: 'x:0.00 y:0.00 z:0.00'}
debug1.tsx:45 ✅ torso: {type: 'Bone', isBone: true, rotation: 'x:0.00 y:0.00 z:0.00'}
debug1.tsx:45 ✅ tail: {type: 'SkinnedMesh', isBone: false, rotation: 'x:0.00 y:0.00 z:0.00'}
debug1.tsx:45 ✅ tail001: {type: 'Bone', isBone: true, rotation: 'x:0.69 y:-0.09 z:0.37'}
debug1.tsx:45 ✅ tail002: {type: 'Bone', isBone: true, rotation: 'x:0.75 y:-0.19 z:-0.33'}
debug1.tsx:45 ✅ tail003: {type: 'Bone', isBone: true, rotation: 'x:-0.48 y:-0.27 z:-0.73'}
debug1.tsx:45 ✅ tail004: {type: 'Bone', isBone: true, rotation: 'x:-0.33 y:-0.04 z:-0.06'}
debug1.tsx:45 ✅ ear.L: {type: 'Bone', isBone: true, rotation: 'x:0.00 y:0.00 z:0.00'}
debug1.tsx:45 ✅ ear.R: {type: 'Bone', isBone: true, rotation: 'x:0.00 y:0.00 z:0.00'}
debug1.tsx:45 ✅ head: {type: 'Bone', isBone: true, rotation: 'x:1.41 y:-0.00 z:0.00'}
debug1.tsx:45 ✅ MCH-eye_commonparent: {type: 'Bone', isBone: true, rotation: 'x:-1.57 y:0.00 z:0.00'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:-0.42 y:0.35'}
debug1.tsx:160 🔄 자동 회전: {head_y: '-0.054'}
debug1.tsx:160 🔄 자동 회전: {head_y: '0.134'}
debug1.tsx:160 🔄 자동 회전: {head_y: '0.197'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:-0.04 y:0.19'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:0.62 y:0.74'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:0.65 y:0.75'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:-0.07 y:0.91'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:-0.96 y:0.04'}
debug1.tsx:121 🦊 꼬리 흔들기: {wag: '1.00', tail_y: '0.30', tail1_y: '0.25'}
debug1.tsx:105 💃 엉덩이 흔들기: {sway: '-0.100', hips_rotation_z: '-0.100', hips_position_x: '-0.005'}
debug1.tsx:105 💃 엉덩이 흔들기: {sway: '0.061', hips_rotation_z: '0.061', hips_position_x: '0.003'}
debug1.tsx:105 💃 엉덩이 흔들기: {sway: '0.020', hips_rotation_z: '0.020', hips_position_x: '0.001'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:0.51 y:0.55'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:-0.62 y:-0.19'}
debug1.tsx:147 👀 시선 추적: {headCommon_x: '-1.571', headCommon_y: '0.000', pointer: 'x:-0.39 y:0.00'}
debug1.tsx:160 🔄 자동 회전: {head_y: '-0.084'}
debug1.tsx:160 🔄 자동 회전: {head_y: '0.108'}
debug1.tsx:160 🔄 자동 회전: {head_y: '0.200'}// 눈알 메쉬 재질 런타임 교체 (개념)
// GLB 로드 후 눈알에 해당하는 mesh를 찾아서
// MeshPhysicalMaterial로 직접 교체
// - transmission: 1 (빛이 통과하는 굴절 효과)
// - ior: 1.45 (유리 굴절률)
// - thickness: 0.5 (재질 두께감)
// - roughness: 0 (매끈한 표면)
// 눈알 반짝이는 재질
const eyeTextures = useTexture(
enhancedEyes
? [
'/character/textures/eyes_color.png',
'/character/textures/eyes_roughness.png',
'/character/textures/eyes_transmission.png',
]
: [],
);
// 눈알 재질 대체
const eyeMaterial = useFundyEyeMaterial(eyeTextures);
useApplyEyeMaterials({ enhancedEyes, eyeMaterial, nodes, materials });
/**
* 눈알/홍채 재질을 모델에 적용하는 훅
*/
export function useApplyEyeMaterials(params: {
enhancedEyes: boolean;
eyeMaterial?: THREE.Material;
nodes: GLTFResult['nodes'];
materials: GLTFResult['materials'];
}) {
const { enhancedEyes, eyeMaterial, nodes, materials } = params;
useEffect(() => {
const eyeMat = enhancedEyes && eyeMaterial ? eyeMaterial : materials.eye;
const irisMat = materials.iris;
const eyeMeshes = [nodes.Sphere001, nodes.Sphere003].filter(
(mesh): mesh is THREE.Mesh => !!mesh && (mesh as THREE.Mesh).isMesh,
);
const irisMeshes = [nodes.Sphere001_1, nodes.Sphere003_1].filter(
(mesh): mesh is THREE.Mesh => !!mesh && (mesh as THREE.Mesh).isMesh,
);
eyeMeshes.forEach(mesh => {
mesh.material = eyeMat;
mesh.material.needsUpdate = true;
});
irisMeshes.forEach(mesh => {
mesh.material = irisMat;
});
}, [enhancedEyes, eyeMaterial, materials.eye, materials.iris, nodes]);
}// useFixSkinnedMesh.ts
export function useFixSkinnedMesh(scene: THREE.Group | THREE.Object3D) {
useEffect(() => {
if (!scene) return;
scene.traverse(child => {
if (child instanceof THREE.SkinnedMesh) {
// frustumCulled = false : 화면 밖 요소도 렌더링 포함
child.frustumCulled = false;
// Geometry 유효성 검사 및 boundingBox 수동 보정 : 실제 크기로 렌더링 범위 재계산
const geo = child.geometry;
if (geo?.attributes?.position && geo.attributes.position.count > 0) {
try {
if (!geo.boundingBox) geo.computeBoundingBox();
if (!geo.boundingSphere) geo.computeBoundingSphere();
} catch (error) {
console.warn(`Failed to compute bounds for ${child.name}:`, error);
}
}
// MorphTargets 초기화 : 표정값 초기 상태로 정리
if (child.morphTargetInfluences && child.morphTargetDictionary) {
// morphTargetInfluences가 undefined인 경우 초기화
const morphCount = Object.keys(child.morphTargetDictionary).length;
if (child.morphTargetInfluences.length !== morphCount) {
child.morphTargetInfluences = new Array(morphCount).fill(0);
}
}
}
});
}, [scene]);
}/**
* FundyModel.tsx
* SkeletonUtils를 활용한 독립적 인스턴싱 구현
*/
export const FundyModel = forwardRef<THREE.Group, FundyModelProps>(
({ animation, enhancedEyes = true, trophyHold = false, ...props }, ref) => {
const group = useRef<THREE.Group>(null!);
const [actionClips, setActionClips] = useState<FundyActionClips>({});
// 1. GLTF 로드 (자동 캐싱)
const { scene, animations } = useGLTF('/character/model.glb') as unknown as GLTFResult;
// 2. 씬 복제: SkeletonUtils.clone을 사용해야 인스턴스마다 독립적인 애니메이션 가능
const clone = useMemo(() => SkeletonUtils.clone(scene), [scene]);
// 3. 복제된 씬에서 nodes와 materials 다시 추출
const { nodes, materials } = useGraph(clone) as unknown as GLTFResult;
// 4. 독립적인 AnimationMixer 연결
const { actions, clips, mixer } = useAnimations(animations, group);
// [중략: 애니메이션 클립 매핑 로직]
// 5. 하이브리드 최적화 적용
useFixSkinnedMesh(clone); // 렌더링 에러 보정 훅
useMorphAnimation(nodes, animation); // 표정(Morph Target) 제어 훅
return (
<group ref={group} {...props} dispose={null}>
<group name="Scene">
<group name="bone_body">
{/* SkinnedMesh와 Bone 구조 렌더링 */}
<Primitive object={nodes['DEF-spine']} />
<skinnedMesh
name="body"
geometry={nodes.body.geometry}
material={materials.body}
skeleton={nodes.body.skeleton}
/>
{/* ...기타 파츠 */}
</group>
</group>
</group>
);
}
);// ORM 합성 스크립트 핵심 로직
const orm = Buffer.alloc(SIZE * SIZE * 3);
for (let i = 0; i < SIZE * SIZE; i++) {
orm[i * 3 + 0] = ao[i]; // R 채널에 AO
orm[i * 3 + 1] = rough[i]; // G 채널에 Roughness
orm[i * 3 + 2] = metal[i]; // B 채널에 Metallic
}
await sharp(orm, { raw: { width: SIZE, height: SIZE, channels: 3 } })
.png({ colours: 256 }) // PNG-8로 용량 다이어트
.toFile(`${name}_ORM.png`);온보딩 시스템 개선부터 인프라 모니터링, 실시간 소켓 통신, 그리고 3D 캐릭터 최적화까지 7주간의 그룹 프로젝트 완주 기록
Open Source Contribution Academy(OSCCA) 챌린지 기간 동안 Githru 프로젝트에 참여하며 이슈 제기와 첫 Pull Request를 경험한 기록

6주간의 그룹 스프린트를 돌아보며 경험한 설계의 시행착오, 협업의 기술, 그리고 시니어 피드백을 통한 성장을 정리한 기록

funda 프로젝트와 개인 포트폴리오에 Three.js로 캐릭터를 올리며 겪은 문제들을 적은 기록
베이직부터 그룹 프로젝트까지, CS 지식을 체득하고 AI 엔지니어링과 설계의 본질을 깨달으며 성장한 7개월간의 기록
온보딩 시스템 개선부터 인프라 모니터링, 실시간 소켓 통신, 그리고 3D 캐릭터 최적화까지 7주간의 그룹 프로젝트 완주 기록
아이디어 선정부터 시니어 피드백을 거쳐 MVP 구현까지, 그룹 프로젝트의 기틀을 다지며 겪은 시행착오와 기술적 도전의 기록
벡터의 길이를 1로 만드는 정규화의 본질을 게임 이동 로직과 블렌더의 Apply Scale 사례를 통해 분석했습니다.
6주간의 그룹 스프린트를 돌아보며 경험한 설계의 시행착오, 협업의 기술, 그리고 시니어 피드백을 통한 성장을 정리한 기록
10주 동안 진행된 부스트캠프 멤버십 학습 스프린트를 돌아보며 기술 학습, 설계 고민, 번아웃, 그리고 AI 활용까지 정리한 기록
네이버 부스트캠프 웹·모바일 10기 챌린지 과정 - 데일리 미션, CS 공부, 피어 피드백, AI 활용 방안
함수형 프로그래밍과 객체 지향 프로그래밍의 장단점, 리액트에서 함수형을 선택한 이유에 대한 탐구에 대한 기록
네이버 부스트캠프 웹·모바일 10기 베이직 과정 지원 준비부터 수료, 문제 해결력 테스트 후기까지 정리한 기록