Captain's Log
← Observatory
Observatory·

코드로 빚은 3D 아이콘 15종 — AI와 함께 판 파이프라인, 그리고 미완의 품질

디자인 시스템에 넣을 3D 아이콘 15종을 AI 에이전트와 함께 전부 코드로 만들었다. bpy 프로시저럴 모델링부터 Cycles 프리렌더, join()의 좌표계 함정과 오일러 순서의 배신까지 — 시행착오의 전 과정을 기록한다. 결론부터: 품질이 실사용 기준에 못 미쳐 배치는 보류했다. 대신 파이프라인은 남았고, AI의 발전에 맞춰 계속 갱신해 실사용까지 끌고 갈 계획이다.

TrainingAIBlender3DThree.jsDesign System

출발 — 아이콘을 그리지 않고 '생성'할 수 있을까

웹과 앱 전역에서 쓸 3D 아이콘 세트가 필요했다. 이 사이트에는 이미 Blender 모델을 웹에 올리는 파이프라인이 있으니 렌더링 쪽은 걱정이 없었다. 문제는 에셋이다. 아이콘 15종 — 우주함선, LLM, RAG, 나침반, 톱니바퀴… — 을 하나하나 손으로 모델링하면 며칠이 간다.

그래서 이번 항해의 가설은 이랬다. AI 에이전트(Claude Code)와 함께, 에셋 제작부터 웹 반영까지 전부 코드로 해결할 수 있을까?

먼저 웹 쪽 구조부터 정리했다. 3D 아이콘을 "모든 화면"에서 쓰려면 아이콘마다 WebGL 캔버스를 깔 수는 없다 — 브라우저의 WebGL 컨텍스트는 페이지당 8~16개가 한계다. 그래서 정적 우선 하이브리드로 설계했다:

  • lib/icons3d.ts — 이름 → glb/이미지/라벨 레지스트리 (단일 소스)
  • <Icon3D name="llm" /> — 평소엔 프리렌더된 정적 이미지. SSR OK, 개수 무제한
  • <IconSet3D> — 히어로 자리 전용 라이브 WebGL (단일 캔버스에 여러 모델)
  • 프리렌더 파이프라인 — glb를 투명 배경 PNG로 자동 변환

관문 1 — 에셋을 어디서 구할 것인가

첫 계획은 AI 텍스트→3D 생성(Tripo, Meshy, Hunyuan3D-2)이었다. 15종의 프롬프트 세트까지 만들었지만 — 추상 개념은 은유로 바꿔야 한다. LLM은 "노드로 연결된 뇌", XAI는 "속이 들여다보이는 투명한 뇌" — 실행 단계에서 접근 문제로 막혔다. 무료 에셋 쪽도 뒤졌다. 유명한 3D 아이콘 라이브러리는 유료화됐고, CC0 저장소에는 필요한 형태가 드물었다.

남은 길은 하나. Blender의 Python API(bpy)로 15종을 전부 프로시저럴 생성하는 것. 알고 보니 이 길이 가장 이 항해다웠다:

AI 생성bpy 스크립트
스타일 통일프롬프트 가챠코드로 보장
브랜드 컬러 #00DF82근사치정확한 값
수정재생성 반복코드 한 줄 → 재실행
용량수십만 폴리곤로우폴리 5~185KB

pip install bpy 한 방으로 서버 환경에서도 Blender가 돌아간다는 것도 이번에 알았다. 생성(make_icons.py) → Cycles 렌더(render_icons.py)까지 헤드리스로 완결된다.

관문 2 — join()의 좌표계 함정

첫 결과물은 절반의 성공이었다. 원기둥·구·토러스 조합으로 15종이 다 나왔는데, 스티키 노트가 45° 돌아간 다이아몬드로 렌더됐다. 나침반은 아래에서 올려다본 각도. 코드 어디에도 45°는 없는데.

범인은 Blender의 join()이었다. join은 활성(active) 오브젝트의 로컬 좌표계로 병합하는데, bpy.data.objects는 생성 순이 아니라 이름의 알파벳 순이다. 노트의 부품 중 "Cone"(45° 회전된 접힘 장식)이 "Cube"보다 앞서 활성이 됐고, 병합된 지오메트리 전체에 그 역회전이 스며들었다. 이후 포즈 지정이 오브젝트 회전을 덮어쓰는 순간 잔류 회전이 그대로 베이크된 것.

# join 전에 모든 부품의 회전을 버텍스에 굽는다 —
# 활성 오브젝트가 무엇이든 결과가 같아진다
bpy.ops.object.transform_apply(rotation=True, scale=True)
bpy.ops.object.join()

두 번째 함정은 더 조용했다. glTF로 임포트한 오브젝트는 회전 모드가 QUATERNION이라, rotation_euler에 값을 넣어도 아무 일도 일어나지 않는다. 에러도 없다. 외부 에셋 변환기를 만들다 발견했고, rotation_mode = "XYZ" 한 줄이 해답이었다.

관문 3 — 오일러 순서의 배신

포즈 시스템에서 제일 오래 헤맨 건 회전 그 자체였다. 함선을 "위에서 내려다본 쿼터뷰"로 눕히려고 X축 48°(피치) + Z축 150°(요)를 줬더니, 결과는 아래에서 올려다본 앵글이었다.

Blender의 XYZ 오일러는 X → Y → Z 순으로 적용된다. 즉 카메라 쪽으로 기울인 피치를, 그 다음의 요 회전이 통째로 반대편으로 돌려버린다. "방향을 먼저 돌리고(요) → 카메라 쪽으로 기울인다(피치)"는 의도라면 순서가 반대여야 한다:

obj.rotation_mode = "ZYX"   # Z(요) 먼저 → X(피치) 나중
obj.rotation_euler = (radians(82), 0, radians(150))

재미있는 건 그 반대 순서가 필요한 아이콘도 있다는 것. 책의 두께를 보여주려면 "세운 다음(피치) → 좌우로 돌려야(요)" 한다 — 이건 XYZ 순서다. 같은 세 숫자가 순서에 따라 전혀 다른 포즈가 된다. 결국 포즈 테이블에 회전 순서까지 명시하는 구조로 정리했다.

관문 4 — 품질은 조명이 절반, 단순화가 절반

three.js로 찍던 프리렌더를 Blender Cycles(3점 소프트 스튜디오 + 민트 림 라이트)로 바꾸고, 전 아이콘에 각도 제한 베벨을 베이크하자 분위기가 달라졌다. 유리 구 안에 기어가 굴절되어 비치는 XAI 아이콘은 이 레이트레이서 전환의 가장 큰 수혜자다.

하지만 더 큰 교훈은 단순화였다. 세트에서 좋아 보이는 아이콘(gear, poi, trophy)은 전부 요소가 적고 형태가 굵은 것들이었고, 약해 보이는 것(rag, compass, map)은 잔부품이 많은 것들이었다. 그래서 원칙을 세우고 한 바퀴 다시 돌았다:

  1. 아이콘당 핵심 요소 1~2개 — RAG는 4요소에서 "DB + 돋보기" 2요소로
  2. 청키한 비례 — 더 굵게, 더 두껍게
  3. 시점 통일 — 카메라를 정면으로 고정하고, 면 형태는 전부 카메라를 정확히 보게
  4. 균형 — 바운딩 구 대신 camera_fit_coords로 버텍스에 카메라를 정밀 피팅, 15종이 같은 비율로 프레임을 채우게
3D 아이콘 로딩 중…

결과, 그리고 솔직한 판정

15종이 완성됐다. 정면 시점 통일, 동일 프레임 점유율, 브랜드 민트/차콜, 개당 5~185KB. 전 과정이 코드라 어떤 아이콘이든 함수 하나 고쳐 재실행하면 끝난다.

그럼에도 실사용은 보류한다. 프리미티브 조합의 한계 — 모델링 디테일, 형태의 유기적 비례감 — 는 조명과 단순화로도 다 메워지지 않았다. 전문 3D 아이콘 세트가 주는 밀도에는 못 미친다는 게 냉정한 판정이다. 도구(Blender)가 아니라 조형 감각의 병목이고, 그건 코드 몇 줄로 해결되지 않았다.

항로 계획 — 파이프라인은 남는다

이번 항해의 진짜 수확은 아이콘이 아니라 파이프라인이다:

  • make_icons.py — 15종 프로시저럴 생성 (형태 정의가 곧 코드)
  • render_icons.py — 어떤 glb든 같은 스튜디오 조명으로 렌더 (출처 무관 스타일 통일)
  • import_asset.py — 외부/AI 생성 모델을 브랜드 재질로 자동 리컬러해 세트에 편입
  • <Icon3D> / <IconSet3D> — 정적 우선 하이브리드 웹 컴포넌트

구조적으로 형태의 출처와 스타일이 분리되어 있다. 형태만 더 좋은 것으로 갈아 끼우면 나머지는 그대로 동작한다. 그래서 이 작업은 실패로 닫는 게 아니라 열어 둔다 — AI 텍스트→3D 생성의 품질이 올라오는 속도에 맞춰 이 파이프라인을 계속 갱신하고, 생성 모델이 프로덕션급 형태를 뽑는 시점에 실사용까지 간다는 계획이다. 그때 필요한 건 import_asset.py에 새 glb를 통과시키는 것뿐이다.