AI 기반 3D 파트 그룹핑, Material 생성 도구
손으로 굽던 3D 재질을 브라우저 GPU로 만드는 텍스처 생성 도구
코인 하나에 손으로 쓴 500줄
이 도구를 만들기 전, 3D 에셋에 재질을 입히는 일은 전부 사람 손이었습니다. 같은 조직의 게임 패키지에 있는 packages/101_xyz/src/routes/views/claim/ThreeCoin.tsx 를 열어 보면 그 방식이 그대로 보여요. 코인 하나를 은빛 금속으로 보이게 하려고 <canvas> 에 방사형 그라디언트와 링을 직접 그려 color 맵과 bump 맵을 굽고, MeshPhysicalMaterial 의 metalness: 1, roughness: 0.08, clearcoat: 0.3, envMapIntensity: 1.7 을 값으로 박아 넣어 500줄 넘게 짰습니다. 이게 코인 한 개 분량입니다.
에셋이 늘어나면 이 방식은 에셋 수만큼 반복됩니다. 재질 변형을 하나 더 보려면 그때마다 코드를 고치고 다시 빌드해야 하고요.
요구사항은 이렇게 정리됩니다. GLB를 올리면 파트를 나누고, 재질 후보를 여러 벌 만들어, 조명 아래에서 미리 보고 골라 적용한 뒤, 엔진이 읽을 수 있는 형태로 내보낸다. 사람이 코드로 하던 일을 AI에게 넘기는 것입니다. 여기에 AI 호출 비용을 통제해야 한다는 제약이 하나 더 붙습니다.
여기서는 브라우저가 3D를 떠맡는 역할 분담과 LLM이 만든 PBR 값을 거르는 층, 이 두 가지만 다룹니다. 여덟 방향에서 만든 그림을 UV 아틀라스로 모으는 텍스처 파이프라인은 아래에서 한 문단으로만 짚고, 텍스처 제공자 어댑터와 모델 평가 표는 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접습니다.

브라우저 탭이 유일한 GPU였던 이유
three.js·WebGL·xatlas(WASM)를 브라우저에서 실행
쓸 수 있는 GPU가 아티스트의 브라우저 탭 하나뿐이었습니다. 서버 GPU를 붙일 예산은 없었고, 어차피 아티스트는 결과를 보려고 브라우저를 켜 둡니다. 이미 열려 있는 렌더러를 그대로 계산 자원으로 쓰는 편이 합리적이었어요. xatlasjs 가 인덱스를 uint16으로 다루기 때문에 65k 버텍스가 넘는 메시는 그대로 넣을 수 없었습니다. 청크로 쪼개 넣고 결과를 다시 꿰매는 코드를 직접 써야 했죠.
LLM에게 이미지가 아니라 PBR 파라미터를 만들게 하기
"재질 생성"이라고 하면 보통 텍스처 이미지를 떠올리지만, 실시간 엔진에서 가장 싸고 즉시 반영되는 것은 metalness, roughness, clearcoat 같은 숫자입니다. JSON Schema로 structured output을 받으면 그 숫자가 곧바로 MeshPhysicalMaterial 에 꽂혀요. 텍스처는 그 위에 얹는 선택지로 뒀습니다. 모델이 뱉은 값이 잘못되면 렌더가 조용히 깨집니다. 스키마만으로는 못 잡는 층이 있어서 검증을 따로 짜야 했어요.
모든 AI 호출을 costs 테이블에 적고 월 예산을 넘기면 HTTP 402로 막기
공용 키를 쓰는 사내 도구에서 누가 얼마를 썼는지 모르면 결국 키를 회수하게 됩니다. 호출마다 user·asset·kind와 함께 비용을 적고, assertBudget() 이 생성 직전에 이번 달 지출을 확인합니다. 토큰 단가는 모델마다 다르고 자주 바뀝니다. 정확한 청구서가 아니라 "이번 달 얼마나 태웠나"를 보여 주는 근사치라는 점은 처음부터 인정하고 갔어요.

연산은 브라우저, 기록은 워커
역할 분담이 이 시스템의 뼈대입니다. 브라우저는 3D를, 워커는 기억과 지출을 맡습니다. 아티스트가 GLB를 떨어뜨리면 워커는 R2에 원본을 넣고 assets / asset_versions 행과 analyze 잡을 만든 뒤 바로 응답합니다. 실제 분석은 브라우저가 해요. GLB를 파싱해 파트를 훑고, UV 상태를 ok / distorted / overlapping / missing 으로 판정하고, 파트마다 정면·¾ 썸네일을 렌더해서 POST /assets/:id/analysis 와 PUT …/parts/:id/thumb 로 되돌려 보냅니다. 워커 입장에서는 3D를 만진 적이 없지만 D1에는 파트별 삼각형 수, bbox, UV 왜곡도, 텍셀 밀도가 다 쌓여 있죠.

LLM이 만든 PBR 값을 세 겹으로 거르고, 고친 자리를 전부 기록하기
프롬프트에 "물리적으로 그럴듯한 값을 주세요"라고 적고 응답을 JSON.parse 해서 그대로 머티리얼에 꽂습니다. 조금 더 신경 쓴다면 JSON Schema로 structured output을 걸고, 스키마를 통과했으니 안전하다고 여기고 넘어가죠. 실제로 대부분의 값은 통과합니다. 문제는 통과한 값들 중에 렌더를 망가뜨리는 것이 섞여 있다는 점이에요.
검증을 세 단계로 나눴습니다. ① schema는 타입과 범위를 봅니다. color 가 ^#[0-9a-fA-F]{6}$ 인지, roughness 가 0~1인지. ② constraint는 아티스트가 폴더마다 걸어 둔 규칙을 봅니다. allowed_types, metalness 범위, emissive_allowed, forbidden 필드. ③ physics는 도메인 규칙을 봅니다. roughness < 0.03 은 미러 특이점이라 0.03으로 올리고, metalness 가 0.2~0.8 사이면 유전체도 금속도 아닌 값이라 가까운 극으로 스냅하고, transmission > 0 인데 metalness > 0.2 면 유리가 금속처럼 렌더되므로 0으로 내리고 materialType 을 physical 로 바꿉니다.
위반이 나오면 violationsToFeedback() 이 그걸 사람이 읽는 문장으로 만들어 다음 요청에 붙입니다. "Your previous answer violated these rules, fix them and keep everything else". 최대 2회까지 다시 물어보고, 그래도 남은 것은 clamp합니다. 다만 조용히 고치지 않습니다. 무엇을 어떤 값에서 어떤 값으로 바꿨는지 loadouts.validation_json 에 남기고, 후보 카드에 2 retries · 3 clamped 로 띄웁니다.
세 단계가 각각 다른 종류의 오류를 잡기 때문입니다. 스키마는 metalness: 0.5 를 막지 못해요. 타입도 맞고 범위도 맞으니까요. 그런데 실시간 PBR에서 0.5는 물리적으로 존재하기 어려운 값이라 어느 조명에서 봐도 어정쩡하게 보입니다. 이건 스키마가 아니라 도메인 지식이고, 코드로만 잡힙니다.
재시도를 무한히 돌리지 않은 것도 의도한 선택이에요. 한 번 더 물어보면 대부분 고쳐 오지만, 세 번째부터는 같은 실수를 반복하면서 비용만 늘더군요. 그래서 2회에서 끊고 나머지는 클램프로 마감합니다. 그리고 클램프 기록이 쌓이니 부수 효과가 생겼습니다. "어떤 모델이 어떤 규칙을 자주 어기는가"가 eval_runs 에 그대로 남아서, 대시보드의 schema-ok 비율과 평균 위반 수만 보고 모델을 갈아 끼울 수 있게 됐어요. 감이 아니라 표를 보고 정하게 된 거죠.
여덟 방향에서 생성한 그림을 UV 아틀라스 한 장으로 모으기
뷰마다 텍스처를 만들어 2D 이미지 공간에서 합치려 하면, 어느 픽셀이 UV의 어느 텍셀에 대응하는지 알 수 없어 접합선에서 무너집니다.
방향을 뒤집어, 이미지를 UV로 옮기는 대신 메시를 UV 공간에 그렸습니다. 버텍스 셰이더가 gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0) 을 써서 렌더 타깃 자체가 UV 아틀라스가 되고, 각 프래그먼트는 자기 월드 좌표를 조건 카메라로 투영해 그 뷰의 생성 이미지를 샘플합니다. 정면성 가중치와 깊이 비교로 거른 픽셀만 additive로 쌓고 누적 가중치로 나눕니다.
아틀라스가 렌더 타깃이 되는 순간, UV 차트가 몇 개인지 코드가 알 필요가 없어집니다.
유저가 할 수 있는 일
미뤄 둔 잡 큐가 남긴 숙제
브라우저에 3D를 맡긴 대가는 분명합니다. 50만 삼각형이 넘어가면 언랩 중에 탭이 눈에 띄게 버벅이고, 그 이상은 사실상 못 돌립니다. UV 다이얼로그에 "이 정도 크기는 컨테이너 워커에서 돌아야 합니다"라고 적어 두긴 했지만, 그 컨테이너는 아직 없어요. 지금 모든 잡은 waitUntil 로 인라인 실행되거나 브라우저에서 돕니다. jobs 테이블과 진행률 리포터를 먼저 만들어 둔 건 나중에 Queues나 Workflows로 옮길 때 화면을 안 고치려는 계산이었는데, 결국 그 이사를 이 기간 안에 못 했습니다. 다시 만든다면 이건 처음부터 큐로 갑니다. 인라인 실행은 취소·재시도·타임아웃을 전부 손으로 짜게 만들거든요.














