모바일 메신저 생태계에서 하이퍼캐주얼 3D 경험을 구현하려면, WebGL 기반 렌더링 엔진과 경량 런타임 환경 간의 간극을 메우는 것이 핵심이다. 본 프로젝트는 WeChat 미니게임 플랫폼 위에서 Three.js를 활용해 실시간 3D 콘텐츠를 구동하는 전 과정을 다룬다.
플랫폼 특성과 제약 조건
WeChat 미니게임은 브라우저가 아닌 독자적인 JavaScriptCore/V8 하이브리드 환경에서 동작한다. 이는 표준 Web API의 부재, 4MB 메인 패키지 제한, 그리고 제한적인 파일 시스템 접근을 의미한다. Three.js의 전체 빌드(약 700KB)를 그대로 사용하면 실제 게임 로직을 담기 어려우므로, 모듈별 커스텀 빌드가 필수적이다.
커스텀 Three.js 빌드 및 프로젝트 구조
필요한 핵 모듈만 선별하여 번들 크기를 300KB 이하로 압축한다. 다음은 권장하는 디렉터리 구조다.
/mini-game-3d
├── app.js # 진입점, 글로벌 상태 관리
├── app.json # 미니게임 설정
├── project.config.json # 개발자 도구 설정
├── vendor/
│ └── three.core.min.js # 커스텀 빌드: Scene, Camera, Renderer, 기본 Material
├── src/
│ ├── bootstrap/
│ │ └── EngineLauncher.js # Canvas 초기화, WebGL 컨텍스트 획득
│ ├── world/
│ │ ├── Stage.js # Scene, Camera, Light 통합 관리
│ │ └── Director.js # 씬 전환, 애니메이션 루프 제어
│ ├── assets/
│ │ ├── GeometryFactory.js # 프로시저럴 기하체 생성
│ │ ├── MaterialPalette.js # 재질 캐싱 및 재사용
│ │ └── ModelImporter.js # GLTF/OBJ 로드 어댑터
│ ├── interaction/
│ │ └── TouchInterpreter.js # 터치 입력 → 3D 회전/확대/이동
│ └── utils/
│ └── PathBuilder.js # 환경별 CDN 경로 동적 생성
└── remote-assets/ # 동적 로드 대상 (CDN 호스팅)
├── meshes/
└── environments/
Canvas 컴포넌트 선언 및 WebGL 컨텍스트 획득
WXML에서 type="webgl" 속성을 명시해야 WebGL 렌더링이 가능하다. 표준 DOM API와 달리 wx.createSelectorQuery()를 통해 노드를 획득한다.
<!-- page.wxml -->
<canvas
id="renderTarget"
type="webgl"
style="width: 100vw; height: 100vh; display: block;"
bindtouchstart="handleTouchBegin"
bindtouchmove="handleTouchMove"
bindtouchend="handleTouchRelease"
bindtouchcancel="handleTouchCancel"
/>
JS측에서 Canvas 노드를 획득하여 Three.js 렌더러에 전달하는 과정은 다음과 같다.
// EngineLauncher.js
const { pixelRatio, windowWidth, windowHeight } = wx.getSystemInfoSync();
const query = wx.createSelectorQuery();
query.select('#renderTarget').node().exec(([canvasNode]) => {
const rawCanvas = canvasNode.node;
// 실제 물리 픽셀 크기로 설정 (Retina 대응)
rawCanvas.width = windowWidth * pixelRatio;
rawCanvas.height = windowHeight * pixelRatio;
const glRenderer = new THREE.WebGLRenderer({
canvas: rawCanvas,
antialias: false, // 모바일 성능 고려
alpha: false
});
glRenderer.setPixelRatio(pixelRatio);
glRenderer.setSize(windowWidth, windowHeight, false); // CSS 크기는 별도 설정
glRenderer.setClearColor(0x1a1a2e, 1);
// 그림자 맵 최적화
glRenderer.shadowMap.enabled = true;
glRenderer.shadowMap.type = THREE.PCFSoftShadowMap;
return glRenderer;
});
3D 공간 구성: Scene, Camera, Light
모든 3D 요소의 컨테이너인 Scene, 시점을 결정하는 Camera, 시각적 깊이를 부여하는 Light를 체계적으로 구성한다.
// Stage.js
class Stage {
constructor(canvasWidth, canvasHeight) {
this.universe = new THREE.Scene();
this.universe.fog = new THREE.Fog(0x1a1a2e, 10, 50);
this.eye = new THREE.PerspectiveCamera(
60, // FOV: 모바일에서 60-75도가 적절
canvasWidth / canvasHeight, // 종횡비
0.1, // near plane
100 // far plane
);
this.eye.position.set(0, 3, 8);
this.eye.lookAt(0, 0, 0);
this._setupIllumination();
}
_setupIllumination() {
// 기본 환경광: 전체적인 밝기 보장
const ambient = new THREE.AmbientLight(0x404060, 0.5);
this.universe.add(ambient);
// 주광원: 그림자 생성, 방향성 부여
const keyLight = new THREE.DirectionalLight(0xfff0dd, 1.2);
keyLight.position.set(5, 12, 8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.camera.near = 0.5;
keyLight.shadow.camera.far = 40;
keyLight.shadow.bias = -0.001;
this.universe.add(keyLight);
// 보조광: 윤곽 분리
const rimLight = new THREE.DirectionalLight(0xaaccff, 0.4);
rimLight.position.set(-5, 2, -5);
this.universe.add(rimLight);
}
resizeViewport(w, h) {
this.eye.aspect = w / h;
this.eye.updateProjectionMatrix();
}
get scene() { return this.universe; }
get camera() { return this.eye; }
}
외부 모델 로딩: GLTF 우선 전략
OBJ는 텍스트 기반 파싱으로 느리고 재질 분리가 번거롭다. GLB/GLTF는 바이너리 압축, PBR 재질, 애니메이션 클립, LOD를 내장하므로 모바일 환경에 최적이다.
// ModelImporter.js
import { GLTFLoader } from '../vendor/GLTFLoader.modified.js';
class ModelImporter {
constructor() {
this.gltfParser = new GLTFLoader();
this.textureCache = new Map();
}
async fetchGLB(remoteUrl) {
return new Promise((resolve, reject) => {
this.gltfParser.load(
remoteUrl,
(gltfPackage) => {
const root = gltfPackage.scene;
// 그림자 속성 자동 적용
root.traverse((entity) => {
if (entity.isMesh) {
entity.castShadow = true;
entity.receiveShadow = true;
// 모바일 최적화: 불필요한 정점 속성 제거
if (entity.geometry.attributes.color) {
delete entity.geometry.attributes.color;
}
}
});
resolve({
model: root,
animations: gltfPackage.animations,
clips: gltfPackage.animations.map(a => a.name)
});
},
(xhrProgress) => {
// 로딩 진행률 콜백 가능
const percent = (xhrProgress.loaded / xhrProgress.total) * 100;
console.log(`[GLB] ${remoteUrl}: ${percent.toFixed(1)}%`);
},
(err) => {
console.error(`[GLB] Load failed: ${remoteUrl}`, err);
reject(err);
}
);
});
}
// 다중 병렬 로딩
async batchLoad(urlList) {
const tasks = urlList.map(url => this.fetchGLB(url));
return Promise.allSettled(tasks);
}
}
터치 기반 3D 인터랙션 구현
모바일 터치 이벤트를 Three.js의 3D 공간으로 매핑하려면, 스크린 좌표를 정규화된 디바이스 좌표(NDC)로 변환해야 한다. 여기서는 아케이드 회전 방식을 구현한다.
// TouchInterpreter.js
class TouchInterpreter {
constructor(domCanvas) {
this.surface = domCanvas;
this.activePointers = new Map();
this.previousPositions = new Map();
// 회전 누적값
this.orbitTheta = 0;
this.orbitPhi = Math.PI / 3;
// 제스처 파라미터
this.sensitivity = 0.005;
this.minPhi = 0.1;
this.maxPhi = Math.PI / 2 - 0.1;
}
ingestTouchStart(touchEvent) {
for (const touch of touchEvent.changedTouches) {
this.activePointers.set(touch.identifier, {
startX: touch.clientX,
startY: touch.clientY,
currentX: touch.clientX,
currentY: touch.clientY
});
}
}
ingestTouchMove(touchEvent) {
for (const touch of touchEvent.changedTouches) {
const pointer = this.activePointers.get(touch.identifier);
if (!pointer) continue;
const deltaX = touch.clientX - pointer.currentX;
const deltaY = touch.clientY - pointer.currentY;
pointer.currentX = touch.clientX;
pointer.currentY = touch.clientY;
// 수평 이동 → Yaw 회전
this.orbitTheta -= deltaX * this.sensitivity;
// 수직 이동 → Pitch 회전 (제한 적용)
this.orbitPhi = THREE.MathUtils.clamp(
this.orbitPhi + deltaY * this.sensitivity,
this.minPhi,
this.maxPhi
);
}
return this._computeOrbitPosition(8); // 반경 8
}
_computeOrbitPosition(radius) {
const x = radius * Math.sin(this.orbitPhi) * Math.sin(this.orbitTheta);
const y = radius * Math.cos(this.orbitPhi);
const z = radius * Math.sin(this.orbitPhi) * Math.cos(this.orbitTheta);
return new THREE.Vector3(x, y, z);
}
ingestTouchEnd(touchEvent) {
for (const touch of touchEvent.changedTouches) {
this.activePointers.delete(touch.identifier);
}
}
// 관성 회전을 위한 속도 계산 (선택 구현)
extractVelocity() {
// ... 구현 생략
}
}
애니메이션 루프 및 생명주기 통합
WeChat 미니게임의 onShow/onHide 생명주기와 Three.js의 렌더링 루프를 동기화해야 백그라운드 전환 시 리소스 낭비를 방지할 수 있다.
// Director.js
class Director {
constructor(renderer, stage, inputHandler) {
this.output = renderer;
this.world = stage;
this.input = inputHandler;
this.clock = new THREE.Clock();
this.mixer = null; // AnimationMixer 인스턴스
this.isAnimating = false;
this.frameId = null;
}
assignAnimationMixer(mixerInstance) {
this.mixer = mixerInstance;
}
tick = () => {
if (!this.isAnimating) return;
const delta = this.clock.getDelta();
// 애니메이션 업데이트
if (this.mixer) {
this.mixer.update(delta);
}
// 카메라 위치 업데이트 (터치 입력 반영)
const camPos = this.input.getDesiredCameraPosition?.();
if (camPos) {
this.world.camera.position.lerp(camPos, 0.1);
this.world.camera.lookAt(0, 0, 0);
}
this.output.render(this.world.scene, this.world.camera);
this.frameId = requestAnimationFrame(this.tick);
};
commence() {
if (this.isAnimating) return;
this.isAnimating = true;
this.clock.start();
this.tick();
}
halt() {
this.isAnimating = false;
if (this.frameId) {
cancelAnimationFrame(this.frameId);
this.frameId = null;
}
}
// 앱 생명주기 연동
onApplicationPause() {
this.halt();
// 선택적: WebGL 컨텍스트 보존
this.output.forceContextLoss?.();
}
onApplicationResume() {
// 컨텍스트 복구 후 재시작
this.output.setAnimationLoop?.(null);
this.commence();
}
}
WebGL 컨텍스트 손실 대
모바일 환경에서 메모리 부족으로 WebGL 컨텍스트가 강제 해제될 수 있다. 이에 대한 우아한 복구 메커니즘이 필요하다.
// EngineLauncher.js 내 초기화 로직
const gl = rawCanvas.getContext('webgl', { powerPreference: 'low-power' });
// 컨텍스트 이벤트 리스너 (Three.js 내부에서도 전달됨)
rawCanvas.addEventListener('webglcontextlost', (ev) => {
ev.preventDefault();
director.halt();
console.warn('WebGL context lost - attempting recovery');
});
rawCanvas.addEventListener('webglcontextrestored', () => {
console.log('WebGL context restored');
// 텍처, 버퍼 재생성
director.onApplicationResume();
});
성능 최적화 체크리스트
| 영역 | 전략 | 예상 효과 |
|---|---|---|
| 메쉬 | Draw call 병합, InstancedMesh 활용 | CPU 오버헤드 50% 감소 |
| 텍스처 | WebP/ETC2 압축, 2의 거듭제곱 크기 | 메모리 70% 절약 |
| 그림자 | 그림자 맵 1024×1024 제한, 동적 품질 조절 | GPU 부하 완화 |
| LOD | 거리 기반 메쉬 품질 전환 | 원근감 유지하며 폴리곤 감소 |
| 오디오 | Web Audio API 대신 미니게임 내장 API | 메인 스레드 부하 분산 |
배포 및 검증
WeChat 개발자 도구에서 "체험판" 빌드 후 실제 디바이스에서 프레임 타임을 모니터링한다. wx.getPerformance() API로 렌더링 성능 지표를 수집하고, 저사양 기기에서 30fps 이상 유지를 확인해야 한다.
// 성능 모니터링 예시
const perfData = [];
setInterval(() => {
const now = performance.now();
// 마지막 프레임 시간 기록
if (window.lastFrameTime) {
const frameTime = now - window.lastFrameTime;
perfData.push(frameTime);
if (perfData.length > 60) perfData.shift();
}
window.lastFrameTime = now;
}, 16);
이러한 체계적인 접근을 통해, 제약적인 미니게임 환경에서도 품질 높은 실시간 3D 콘텐츠를 안정적으로 제공할 수 있다.