Skip to content

How to make MD5 for Away3D in Blender?

시작하기

최근 플래시 영역에서 가장 핫한 이슈는 역시 게임인 것 같습니다. 애니팡 포 카톡을 시작으로 제가 아는 범위에서도 벌써 여러개의 게임이 카톡베이스로 출시를 준비 중입니다. 그리고 이러한 게임을 만드는데 플래시가 아주 많이 사용 되고 있습니다. 모바일 뿐만 아니라 웹에서도 징가의 팜빌2, 스퀘어 에닉스의 LEGEND WORLD 데모 등 다양한 시도가 눈에 띄네요. 또 저의 주 관심분야인 인터렉티브와 미디어 아트에서도 플래시의 강력해진 3D 성능을 다양하게 응용 할 수 있을 것 같습니다.

여튼 이러한 3D 컨텐츠를 만드는데 있어서 가장 중요한 부분이 바로 3D 모델의 제작과 이를 플래시에 적용하는 방법입니다. 3D 제작툴에서 플래시로 3D 데이터를 가져오는 방법은 사실 상당히 많이 있습니다. 각종 3D 툴과 파일포멧 그리고 플래시에서 사용할 3D 라이브러리에 따라 별별 방법이 있을 수 있습니다. 오늘은 그 중 블렌더에서 md5를 추출하여 Away3D 에서 사용 하는 방법을 알아보도록 하겠습니다.

목표

이번 강좌의 목표는 블렌더에서 한개의 모델링 데이터와 여러개의 에니메이션을 추출 하여 Away3D 에서 모델링, 에니메이션 데이터를 가지고 오고 키보드 입력으로 에니메이션을 바꿔보는 것 입니다.

준비물

  1. Blender 2.6.4
  2. md5 Exporter 2.6.3
  3. Away3D
  4. model 데이터

Blender

블렌더는 사실 많은 분들에게 익숙하지 않은 툴입니다. 그럼에도 불구하고 블렌더를 사용하는 이유는 첫번째 오픈소스로 무료이며 두번째 대부분의 파일포멧의 exporter 를 가지고 있습니다. 물론 유명벤더가 만드는 제품이 아니라 몇몇 문제가 있는 것도 사실 이지만 익숙해지면 나름 좋은 툴입니다. 그리고 사실 저도 블렌더를 잘 아는게 아니라 블렌더에 관한 설명은 정확하지 않을 수 있다는 점 양해 바랍니다. 여튼 저희는 제가 이미 만들어 놓은 모델링 데이터에서 md5를 추출하는 부분만 살펴 볼 예정입니다. 블렌더의 경우 버전별로 애드온 사용에 제약이 많습니다. 이전 버전에선 잘 되던 것들이 마이너 업그레이드에도 안되는 경우가 많으니 꼭 설치시 각각의 버전을 잘 확인 해야 합니다.

md5

md5 포멧은 원래 게임(Doom3) 개발을 위해 만들어진 3D 포멧으로 일반적으로 file_name.md5mesh 와 file_name.md5anim 로 쌍을 이루고 있습니다. 확장자명을 봐서 알 수 있듯이 file_name.md5mesh 파일은 3D 데이터의 mesh 데이터를 가지고 있고 file_name.md5anim 는 해당 mesh 의 조인트로 만들어진 에니메이션 데이터를 가지고 있습니다. md5 포멧을 이용하면 Away3D 에서 3D 모델의 에니메이션 및 조인트와 키넥트 연동을 이용한 동적인 에니메이션 등을 만들 수 있습니다.

단점이라면 파일포멧 자체가 문자열로 되어있어 최적화가 되지 않는 다는 점과 에니메이션 파일을 이용하면 에니메이션이 정적으로 고정되기 때문에 시뮬레이션 효과등을 적용하기 어렵습니다. 하지만 최적화의 경우 플래시에 embed 시키면 상당히 용량면에서는 절약 할 수 있으며 에니메이션 파일을 사용하지만 않으면 동적이 시뮬레이션도 불가능한 것은 아닙니다.

블렌더 및 플러그인 설치

먼저 위의 준비물을 모두 다운 받습니다. 그리고 적당한 위치에 블렌더를 설치 합니다. 만약 블렌더를 설치버전으로 받아서 설치 하셨다면 대략 아래와 같은 위치에 설치 되었을 겁니다.

아래와 같은 경로에 받은 md5 Exporter 를 설치 합니다.

블렌더를 실행하고 환경설정 창에서 아래와 같이 Import-Export: Export idTech4 (.md5) 애드온을 활성화 합니다.

이제 블렌더에서 md5 파일을 export 할 모든 준비가 되었습니다.

md5 포멧 export

준비물에 있는 model.zip 압축을 해제 하면 들어있는 flag.blend 파일을 엽니다. 블렌더 파일은 약간의 환경설정값도 포함하고 있으므로 아래와 같이 인터페이스가 변경 됩니다. 메인의 좌측은 3D view 이고 우측은 Action Editor 창 입니다.

우측의 Action Editor 창에서 아래와 같이 3가지 타입의 에니메이션을 고를 수 있습니다. Action Editor 는 한개의 모델에 여러개의 에니메이션을 지정 할 수 있게 해 줍니다. 한가지 주의할 점은 에니메이션을 생성하고 반드시 우측의 F 버튼을 눌러줘야 각각의 애니메이션이 저장 됩니다. (저는 이걸 몰라서 몇번을 날려 먹었습니다.) 또 각 오브젝트의 값을 모든 액션에 공통으로 적용되므로 에니메이션이 될 모든 본에 키를 넣어줘야 합니다. 그렇지 않으면 다른 액션에 의해 영향을 받을 수 있습니다.

이제 그럼 md5 를 export 해보도록 하겠습니다. md5 를 export 하기전에 반드시 확인해야 할 것들이 있습니다.

  1. mesh 에 material 이 정의 되었는가?
  2. 모든 mesh 에 Bone(Joint) 가 등록 되었는가?
  3. Bone(Joint)에 에니메이션이 등록 되었는가?

위의 조건 중 한가지라도 충족되지 않으면 md5 로 추출 할 수 없습니다. 또 한가지 재미난 점은 Bone 에 지정되지 않은 mesh 는 에니메이션 상에서 보이지 않게 되므로 모든 mesh 의 vertext 가 Bone 에 등록 되어 있어야 합니다. 위의 조건을 모두 확인 했다면 mesh 와 bone 을 모두 선택한 후 (블렌더에서 선택은 우클릭 입니다. shift + 우클릭 으로 다중선택 할 수 있습니다.) File => Export -> idTech4 md5 (.md5mesh .md5anim) 메뉴를 통해 md5 파일을 export 합니다. 선택시 반드시 mesh를 먼저 선택하고 bone을 선택 해야 합니다.  (이런 사소한것들이 좀 불편합니다;;)  이 과정에서 에러가 발생한다면 대부분의 위의 3가지 조건이 충족되지 않은 경우 입니다.

Away3D 에서 md5 파일 가져오기

Away3D 에서 md5 파일을 가져오는건 이미 예제가 잘 준비 되어 있으므로 Away3D 에서 제공하는 예제를 기본으로 소스를 구현 했습니다. 먼저 전체 소스를 보도록 하겠습니다.

package
{
	import away3d.animators.SkeletonAnimationSet;
	import away3d.animators.SkeletonAnimationState;
	import away3d.animators.SkeletonAnimator;
	import away3d.animators.data.Skeleton;
	import away3d.animators.transitions.CrossfadeStateTransition;
	import away3d.cameras.Camera3D;
	import away3d.containers.Scene3D;
	import away3d.containers.View3D;
	import away3d.controllers.HoverController;
	import away3d.debug.AwayStats;
	import away3d.debug.Trident;
	import away3d.entities.Mesh;
	import away3d.events.AnimationStateEvent;
	import away3d.events.AssetEvent;
	import away3d.library.AssetLibrary;
	import away3d.library.assets.AssetType;
	import away3d.library.assets.IAsset;
	import away3d.loaders.parsers.MD5AnimParser;
	import away3d.loaders.parsers.MD5MeshParser;
	import away3d.materials.ColorMaterial;
	import away3d.materials.TextureMaterial;
	import away3d.materials.methods.EnvMapMethod;
	import away3d.primitives.PlaneGeometry;
	import away3d.primitives.SkyBox;
	import away3d.textures.BitmapCubeTexture;
	import away3d.utils.Cast;

	import flash.display.Sprite;
	import flash.display.StageAlign;
	import flash.display.StageScaleMode;
	import flash.events.Event;
	import flash.events.KeyboardEvent;
	import flash.events.MouseEvent;
	import flash.filters.DropShadowFilter;
	import flash.text.TextField;
	import flash.text.TextFormat;
	import flash.ui.Keyboard;

	import mx.core.mx_internal;

	import tcs.helpers.MathHelper;

	[SWF(width="643", height="362")]
	public class MD5Import extends Sprite
	{
		[Embed(source="/../embeds/flag.md5mesh", mimeType="application/octet-stream")]
		private var Flag_Mesh:Class;

		[Embed(source="/../embeds/flag.md5anim", mimeType="application/octet-stream")]
		private var Flag_Idle:Class;

		[Embed(source="/../embeds/flag_low.md5anim", mimeType="application/octet-stream")]
		private var Flag_Low:Class;

		[Embed(source="/../embeds/flag_high.md5anim", mimeType="application/octet-stream")]
		private var Flag_High:Class;

		[Embed(source="/../embeds/flag.png")]
		private var FlagDiffuse:Class;

		// Environment map.
		[Embed(source="/../embeds/skybox/sky_posX.jpg")]
		private var EnvPosX:Class;
		[Embed(source="/../embeds/skybox/sky_posY.jpg")]
		private var EnvPosY:Class;
		[Embed(source="/../embeds/skybox/sky_posZ.jpg")]
		private var EnvPosZ:Class;
		[Embed(source="/../embeds/skybox/sky_negX.jpg")]
		private var EnvNegX:Class;
		[Embed(source="/../embeds/skybox/sky_negY.jpg")]
		private var EnvNegY:Class;
		[Embed(source="/../embeds/skybox/sky_negZ.jpg")]
		private var EnvNegZ:Class;

		private const ANIM_NAMES:Array = ["idle", "low", "high"];
		private const ANIM_CLASSES:Array = [Flag_Idle, Flag_Low, Flag_High];

		private const FLAG_NUM:int = 50;

		private var view:View3D;
		private var scene:Scene3D;
		private var camera:Camera3D;
		private var cameraController:HoverController;
		private var awayStats:AwayStats;
		private var flagMaterial:TextureMaterial;
		private var animationSet:SkeletonAnimationSet;
		private var skeleton:Skeleton;
		private var mesh:Mesh;
		private var animator:SkeletonAnimator;
		private var stateTransition:CrossfadeStateTransition = new CrossfadeStateTransition(0.5);
		private var _skyBox:SkyBox;
		private var _ground:Mesh;
		private var _flags:Vector.<Mesh>;

		//navigation variables
		private var move:Boolean = false;
		private var lastPanAngle:Number;
		private var lastTiltAngle:Number;
		private var lastMouseX:Number;
		private var lastMouseY:Number;
		private var tiltSpeed:Number = 2;
		private var panSpeed:Number = 2;
		private var distanceSpeed:Number = 1000;
		private var tiltIncrement:Number = 0;
		private var panIncrement:Number = 0;
		private var distanceIncrement:Number = 0;
		private var skyboxMaterial:ColorMaterial;
		private var cubeTexture:BitmapCubeTexture;
		private var text:TextField;

		public function MD5Import()
		{
			super();

			init();
		}

		private function init():void
		{
			initEngine();
			initUI();
			initMaterials();
			initObjects();
			initListeners();
		}

		private function initEngine():void
		{
			stage.scaleMode = StageScaleMode.NO_SCALE;
			stage.align = StageAlign.TOP_LEFT;
			stage.frameRate = 60;

			view = new View3D();
			scene = view.scene;
			camera = view.camera;
			camera.lens.far = 1000000;

			//setup controller to be used on the camera
			cameraController = new HoverController(camera, null, 0, 10, 2500, 3, 70);

			addChild(view);

			awayStats = new AwayStats(view);
			addChild(awayStats);
		}

		private function initUI():void
		{
			text = new TextField();
			text.defaultTextFormat = new TextFormat("Verdana", 11, 0xFFFFFF);
			text.width = 240;
			text.height = 100;
			text.selectable = false;
			text.mouseEnabled = false;
			text.text = "Drag Mouse : hover cameran";
			text.appendText("Numbers 1-3 - Windn");
			text.filters = [new DropShadowFilter(1, 45, 0x0, 1, 0, 0)];

			addChild(text);
		}

		private function initMaterials():void
		{
			flagMaterial = new TextureMaterial(Cast.bitmapTexture(FlagDiffuse));
			flagMaterial.alphaBlending = true;

			//setup the cube texture
			cubeTexture = new BitmapCubeTexture(Cast.bitmapData(EnvPosX), Cast.bitmapData(EnvNegX), Cast.bitmapData(EnvPosY), Cast.bitmapData(EnvNegY), Cast.bitmapData(EnvPosZ), Cast.bitmapData(EnvNegZ));

		}

		private function initObjects():void
		{
			AssetLibrary.addEventListener(AssetEvent.ASSET_COMPLETE, onAssetComplete);
			AssetLibrary.loadData(new Flag_Mesh(), null, null, new MD5MeshParser());

			//scene.addChild(new Trident);

			_ground = new Mesh(new PlaneGeometry(16000, 16000, 8, 8), new ColorMaterial(0xa6793e));
			scene.addChild(_ground);

			_skyBox = new SkyBox(cubeTexture);
			scene.addChild(_skyBox);
		}

		private function onAssetComplete(event:AssetEvent):void
		{

			if (event.asset.assetType == AssetType.ANIMATION_STATE) {
				var state:SkeletonAnimationState = event.asset as SkeletonAnimationState;
				state.looping = true;
				animationSet.addState(event.asset.assetNamespace, state);

				if (state.stateName == ANIM_NAMES[0])
					animator.play(ANIM_NAMES[0], stateTransition);

			} else if (event.asset.assetType == AssetType.ANIMATION_SET) {
				animationSet = event.asset as SkeletonAnimationSet;
				animator = new SkeletonAnimator(animationSet, skeleton);
				for (var i:uint = 0; i < ANIM_NAMES.length; ++i)
					AssetLibrary.loadData(new ANIM_CLASSES[i](), null, ANIM_NAMES[i], new MD5AnimParser());

				mesh.animator = animator;

				for(var j=0; j<FLAG_NUM; j++){
					_flags[j].animator = animator;
				}
			} else if (event.asset.assetType == AssetType.SKELETON) {
				skeleton = event.asset as Skeleton;

			} else if (event.asset.assetType == AssetType.MESH) {
				//grab mesh object and assign our material object
				mesh = event.asset as Mesh;

				_flags = new Vector.<Mesh>();
				var k:Number;
				for(k=0; k<FLAG_NUM; k++){
					_flags[k] = mesh.clone() as Mesh;
					_flags[k].material = flagMaterial;
					_flags[k].scale(150);
					_flags[k].y = 600;
					_flags[k].z = MathHelper.randRange(-4000, 4000);
					_flags[k].x = MathHelper.randRange(-4000, 4000);
					scene.addChild(_flags[k]);
				}

			}
		}

		private function initListeners():void
		{
			addEventListener(Event.ENTER_FRAME, onEnterFrame);
			view.addEventListener(MouseEvent.MOUSE_DOWN, onMouseDown);
			view.addEventListener(MouseEvent.MOUSE_UP, onMouseUp);
			stage.addEventListener(Event.RESIZE, onResize);
			stage.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);
			onResize();
		}

		private function onEnterFrame(event:Event):void
		{
			if (move) {
				cameraController.panAngle = 0.3*(stage.mouseX - lastMouseX) + lastPanAngle;
				cameraController.tiltAngle = 0.3*(stage.mouseY - lastMouseY) + lastTiltAngle;
			}

			cameraController.panAngle += panIncrement;
			cameraController.tiltAngle += tiltIncrement;
			cameraController.distance += distanceIncrement;

			view.render();
		}

		/**
		 * Mouse down listener for navigation
		 */
		private function onMouseDown(event:MouseEvent):void
		{
			move = true;
			lastPanAngle = cameraController.panAngle;
			lastTiltAngle = cameraController.tiltAngle;
			lastMouseX = stage.mouseX;
			lastMouseY = stage.mouseY;
			stage.addEventListener(Event.MOUSE_LEAVE, onStageMouseLeave);
		}

		/**
		 * Mouse up listener for navigation
		 */
		private function onMouseUp(event:MouseEvent):void
		{
			move = false;
			stage.removeEventListener(Event.MOUSE_LEAVE, onStageMouseLeave);
		}

		/**
		 * Mouse stage leave listener for navigation
		 */
		private function onStageMouseLeave(event:Event):void
		{
			move = false;
			stage.removeEventListener(Event.MOUSE_LEAVE, onStageMouseLeave);
		}

		private function onKeyDown(event:KeyboardEvent):void
		{
			switch (event.keyCode) {
				case Keyboard.NUMBER_1:
					animator.play(ANIM_NAMES[0], stateTransition);
					break;
				case Keyboard.NUMBER_2:
					animator.play(ANIM_NAMES[1], stateTransition);
					break;
				case Keyboard.NUMBER_3:
					animator.play(ANIM_NAMES[2], stateTransition);
					break;
			}
		}

		private function onResize(event:Event = null):void
		{
			view.width = stage.stageWidth;
			view.height = stage.stageHeight;

			awayStats.x = stage.stageWidth - awayStats.width;
		}
	}
}

소스가 조금 길지만 대부분 Away3D 를 설정하는 부분과 인터렉션을 위한 부분입니다. 주의깊게 봐야 할 부분만 체크해 보겠습니다.

		...
			AssetLibrary.addEventListener(AssetEvent.ASSET_COMPLETE, onAssetComplete);
			AssetLibrary.loadData(new Flag_Mesh(), null, null, new MD5MeshParser());
		}
		...
		private function onAssetComplete(event:AssetEvent):void
		{

			if (event.asset.assetType == AssetType.ANIMATION_STATE) {
				var state:SkeletonAnimationState = event.asset as SkeletonAnimationState;
				state.looping = true;
				animationSet.addState(event.asset.assetNamespace, state);

				if (state.stateName == ANIM_NAMES[0])
					animator.play(ANIM_NAMES[0], stateTransition);

			} else if (event.asset.assetType == AssetType.ANIMATION_SET) {
				animationSet = event.asset as SkeletonAnimationSet;
				animator = new SkeletonAnimator(animationSet, skeleton);
				for (var i:uint = 0; i < ANIM_NAMES.length; ++i)
					AssetLibrary.loadData(new ANIM_CLASSES[i](), null, ANIM_NAMES[i], new MD5AnimParser());

				mesh.animator = animator;

				for(var j=0; j<FLAG_NUM; j++){
					_flags[j].animator = animator;
				}
			} else if (event.asset.assetType == AssetType.SKELETON) {
				skeleton = event.asset as Skeleton;

			} else if (event.asset.assetType == AssetType.MESH) {
				//grab mesh object and assign our material object
				mesh = event.asset as Mesh;

				_flags = new Vector.<Mesh>();
				var k:Number;
				for(k=0; k<FLAG_NUM; k++){
					_flags[k] = mesh.clone() as Mesh;
					_flags[k].material = flagMaterial;
					_flags[k].scale(150);
					_flags[k].y = 600;
					_flags[k].z = MathHelper.randRange(-4000, 4000);
					_flags[k].x = MathHelper.randRange(-4000, 4000);
					scene.addChild(_flags[k]);
				}

			}
		}

onAssetComplete 는 소스에서 가장 핵심적인 함수 입니다. AssetLibrary.loadData(new Flag_Mesh(), null, null, new MD5MeshParser()); 로 mesh 데이터가 모두 로드되면 로드된 mesh 에 메터리얼을 등록하고 에니메이터를 만들 수 있습니다. md5 는 Bone 을 이용한 에니메이션을 사용하므로 SkeletonAnimationSet 셋에 SkeletonAnimator 와 Skeleton 을 등록하여 에니메이션을 컨트롤 합니다. Bone, Joint 그리고 Skeleton 조금씩 용어는 다르지만 같은 개념이라고 이해하여도 큰 무리는 없습니다. 중요한건 이 Skeleton 을 움직여 mesh 가 에니메이션 된다는 점입니다. 그러므로 만약 우리가 이 Skeleton 을 컨트롤 할 수 있다면 꼭 .md5anim 가 아니여도 mesh 데이터에 에니메이션을 줄 수 있습니다.

		private function onKeyDown(event:KeyboardEvent):void
		{
			switch (event.keyCode) {
				case Keyboard.NUMBER_1:
					animator.play(ANIM_NAMES[0], stateTransition);
					break;
				case Keyboard.NUMBER_2:
					animator.play(ANIM_NAMES[1], stateTransition);
					break;
				case Keyboard.NUMBER_3:
					animator.play(ANIM_NAMES[2], stateTransition);
					break;
			}
		}

에니메이터에 등록된 stateName 을 이용하여 에니메이션 합니다. 요기서 한가지 주의깊게 볼 부분은 CrossfadeStateTransition 클래스의 인스턴스인 stateTransition 입니다. 각 에니메이션 사이를 CrossFade 하여 자연스러운 에니메이션을 만들어 주고 있습니다.

마치며

사실 Away3D 에서 md5 포멧을 사용하는건 워낙 예제가 잘 되어 있기 때문에 크게 어렵지 않습니다. 실제로 가장 어려운 부분은 오히려 정상적으로 작동하는 md5 파일을 만들어 내는 일 입니다. 특히 사용되는 프로그램과 플러그인이 모두 사용자들에 의해 만들어진 것들이라 사용이 편리하지만은 않습니다. 저도 수 많은 시행착오를 거쳐서야 겨우 저 flag.md5mesh 와 flag.md5anim 를 만들 수 있었습니다. 그런 점에서 이번 강좌가 블렌더를 이용하여 플래시에서 3D 데이터를 사용하시려는 분들에게 조금이나마 도움이 되었으면 합니다.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.