드로우콜 2857개를 218개로 줄인 밤: three.js InstancedMesh와 거울 변환 함정
제가 만든 3D 웹게임 EBB & GRAB에서 정적 프롭을 InstancedMesh로 병합해 드로우콜을 2857개에서 218개로 줄인 기록입니다. 그 과정에서 마주친 거울 변환 함정도 같이 정리했습니다.
- 웹게임
- 트러블슈팅
- 제품 개발
EBB & GRAB(썰물의 도둑)은 제가 만든 three.js 3D 쿼터뷰 게임입니다. 빌드 스텝 없이 순수 정적 파일이고, 마을과 바닷속을 오가며 밀물이 들어오기 전에 전리품을 회수하는 구조입니다.
게임 1차 완성 커밋(bd60125)을 찍은 게 2026년 8월 8일 밤 10시 27분이었습니다. 그리고 그날 밤 11시대에 최적화 커밋이 몰아쳤습니다.
미사용 모델 제거(438359b, 23:33), 드로우콜 병합(37f3651, 23:37), 벡터 재사용(230bbc1, 23:42), 회귀 검증 하네스 추가(a0d3a9d, 23:44)까지 11분 사이에 네 개입니다. 이 글은 그중 드로우콜 병합 하나를 뜯어봅니다.
은행 한 채가 메시 80개였습니다
마을 프롭(은행, 조선소, 오두막 등)을 Blender에서 만들고 gltf로 내보냈는데, 익스포터가 색깔이 다른 부분마다 프리미티브를 따로 잘라 뒀습니다.
instancing.js에 남긴 주석 그대로 옮기면 이렇습니다.
프롭은 생성자에서 한 번 배치되고 이후 움직이지 않는다. 그런데 모델 하나가 색깔마다 프리미티브로 쪼개져 있어서(은행 한 채가 메시 80개) 프레임마다 드로우콜 수천 개가 나간다.
마을 하나에 은행, 조선소, 오두막이 여러 채씩 서 있으니 다 합치면 그날 밤 잰 수치가 프레임당 드로우콜 2,857개였습니다.
정확히 어떤 스크립트로 이 숫자를 찍었는지는 커밋 메시지에만 남아 있고, 저장소에 측정 스크립트로 커밋된 건 없습니다. renderer.info.render.calls를 콘솔에서 바로 찍어 봤을 가능성이 높은데, 그날 기록을 다시 봐도 그 부분만 확실하지 않습니다. 재현하려면 이 부분은 제가 다시 짚어봐야 합니다.
지오메트리·머티리얼·거울 여부로 묶기
프롭은 생성 이후 움직이지 않으니 같은 지오메트리와 머티리얼을 쓰는 것끼리는 InstancedMesh 하나로 합칠 수 있습니다. 실제 병합 함수(src/instancing.js)는 이렇습니다.
export function bakeInstances(roots) {
scene.updateMatrixWorld(true);
const groups = new Map();
for (const root of roots) {
root.traverse(o => {
if (!o.isMesh || o.isSkinnedMesh) return;
const material = Array.isArray(o.material) ? o.material[0] : o.material;
if (!material) return;
const mirrored = o.matrixWorld.determinant() < 0;
const key = `${o.geometry.uuid}|${material.uuid}|${mirrored}`;
let g = groups.get(key);
if (!g) {
g = {
geometry: o.geometry, material, mirrored, matrices: [],
castShadow: o.castShadow, receiveShadow: o.receiveShadow,
};
groups.set(key, g);
}
g.matrices.push(o.matrixWorld.clone());
});
root.parent?.remove(root);
}
const local = new THREE.Matrix4();
const inverse = new THREE.Matrix4();
for (const g of groups.values()) {
const mesh = new THREE.InstancedMesh(g.geometry, g.material, g.matrices.length);
if (g.mirrored) mesh.scale.x = -1;
mesh.updateMatrix();
mesh.updateMatrixWorld(true);
inverse.copy(mesh.matrixWorld).invert();
for (let i = 0; i < g.matrices.length; i++) {
mesh.setMatrixAt(i, local.multiplyMatrices(inverse, g.matrices[i]));
}
mesh.instanceMatrix.needsUpdate = true;
mesh.castShadow = g.castShadow;
mesh.receiveShadow = g.receiveShadow;
mesh.matrixAutoUpdate = false;
mesh.computeBoundingSphere();
scene.add(mesh);
}
return groups.size;
}그룹 키가 geometry.uuid | material.uuid | mirrored 세 개인 게 핵심입니다. 지오메트리와 머티리얼이 같아도 거울 여부가 다르면 다른 그룹으로 쪼갭니다. 이유는 다음 문제 때문이었습니다.
거울 변환이 InstancedMesh에서는 다르게 동작합니다
Blender 익스포터가 좌표계를 맞추면서 프롭 대부분의 X축을 뒤집어 뒀습니다. 그러면 월드 행렬의 판별식(determinant)이 음수가 됩니다.
일반 Mesh라면 문제가 안 됩니다. three.js가 오브젝트 단위로 이 판별식을 확인해서 앞면(front face) 방향을 알아서 뒤집어 주기 때문입니다.
그런데 InstancedMesh는 다릅니다. three.js는 InstancedMesh 자기 자신의 행렬만 보고 앞면 방향을 판단하고, 인스턴스마다 다른 행렬은 보지 않습니다. 거울 프롭과 정상 프롭을 같은 InstancedMesh에 섞어 넣으면 절반은 뒤집힌 채로 렌더링됩니다.
해결은 두 단계입니다.
- 그룹을 나눌 때부터
mirrored여부를 키에 포함해서, 거울 프롭과 정상 프롭이 애초에 섞이지 않게 합니다. - 거울 그룹은
InstancedMesh본체에scale.x = -1을 걸어 반전을 본체 쪽으로 옮기고, 각 인스턴스 행렬에는 그 본체 행렬의 역행렬을 곱해서 판별식을 다시 양수로 되돌립니다(inverse.copy(mesh.matrixWorld).invert()부분).
이렇게 하면 InstancedMesh 본체는 뒤집힌 채로 렌더러가 앞면을 올바르게 처리하고, 인스턴스 행렬 쪽 부호는 정상으로 남아서 각 인스턴스의 위치·회전이 깨지지 않습니다.
결과와 남은 것
드로우콜은 2,857개에서 218개로 줄었습니다. 약 13분의 1입니다. SkinnedMesh는 애초에 인스턴싱 대상에서 제외했으니(if (!o.isMesh || o.isSkinnedMesh) return;) 플레이어나 몹처럼 움직이는 캐릭터는 이 최적화의 영향을 받지 않습니다.
미결로 남긴 것도 있습니다.
- 2,857과 218이라는 숫자를 정확히 어떤 절차로 측정했는지는 커밋 메시지에만 남아 있고 재현 스크립트가 없습니다.
renderer.info.render.calls를 직접 콘솔에서 찍었을 텐데, 그날 그렇게 했다는 것 자체를 지금 다시 확인할 방법이 없습니다. - 그날 밤 같이 커밋된 회귀 검증 하네스(
tools/capture.js,a0d3a9d)는 고정 카메라 7개 자세로 스크린샷을 찍어 최적화 전후 픽셀 차이를 비교하는 용도인데, 드로우콜 수치 자체를 기록하는 기능은 없습니다. 다음에 비슷한 최적화를 하면window.__stats()에renderer.info.render.calls를 추가해 두는 게 맞겠습니다.
이 최적화는 정적 프롭에만 적용됩니다. 씬 전체 드로우콜 중 캐릭터, 파티클, 물 셰이더처럼 매 프레임 바뀌는 것들은 여전히 따로 관리해야 합니다.