什么是等角投影(isometric)?
原作者:菩提树下的杨过
出处:http://yjmyzz.cnblogs.com
刚接触这个概念时,我也很茫然,百度+google了N天后,找到了一些文章:
[转载]等角(斜45度)游戏与数学 ( 原文链接:http://www.javaeye.com/articles/1225)
[转载]使用illustrator和正交投影原理以及基本三视图制图 (http://www.vanqy.cn/index.php/2009/03/working-with-orthographic-projections-and-basic-isometrics/)
以及这篇ppt:http://files.cnblogs.com/yjmyzz/Isometric.rar
建议先耐心看完这三篇文章,再往下看:
在之前学习的3D基础( http://www.cnblogs.com/yjmyzz/archive/2010/05/08/1730697.html )、3D线条与填充 ( http://www.cnblogs.com/yjmyzz/archive/2010/05/14/1735154.html)、背面剔除与 3D 灯光 ( http://www.cnblogs.com/yjmyzz/archive/2010/06/06/1752674.html)中,我们所采用的3D坐标系,基本上都属于3D透视投影坐标。通俗点讲:就是物体距离观察者越远,看上去就越小,最终消失在远处的某个点,也可以俗称为“带消失点的3D投影”。这种投影方法虽然精确,但是动画编程中严格按照它来处理,开销很大,计算量也很大,因为不同的z轴距离,或者距离观察点的位置不一样,物体的大小就要调整(如果考虑到光源等因素,处理量就更大了)。
而等角投影中,没有消失点,观察者的目光始终是平行的,投影方向与坐标轴的角度是固定值,虽然这样看上去略有失真,但是总体来讲立体感还是很明显的,重要的是:不管你把等角投影所形成的立体图形放在屏幕上哪一个位置,看上去都是相同的。
原书作者还给出了一个演示,用于帮助大家理解:在线演示
很明显:一个立方体的(正方形)顶部面,在经过等角投影后,在屏幕上会发生形变,成为一个菱形。(点击刚才的在线演示中的true isometric按钮,观察front视图中立方体的顶部)
上图是正方形经过标准等角投影后得到的菱形,其左右侧的角度为60度,通过计算可以得到长宽比例为1.73,但是这个比例通常在计算时,会弄出很多小数位,而且绘图师们也比较烦这个比例(因为用ps等软件画图时,同样也要设置长或宽为小数位才能保证这个比例)
所以在实际情况中,更常用的是"二等角"来代替"等角"(点击刚才的在线演示中的dimetric按钮,观察front视图中立方体的顶部)
可以看出,“二等角投影”形成的菱形要比“等角投影”更扁一些,但这种图形的宽/高比例正好是2,处理起来很方便,也好记忆。
有了上面这些基础,就可以来做些正经事儿了,思考一个问题:在常规3D空间中的图形,经过二等角投影(为方便起见,以下把二等角投影也通称为等角投影)后,要经过怎样的计算(或转换),才能得到最终的图形呢?
有鉴于任何几何图形,总是由若干个点连接而成的,我们先来定义一个常规的Point3D类:
1 package { 2 public class Point3D { 3 public var x:Number; 4 public var y:Number; 5 public var z:Number; 6 public function Point3D(x:Number=0,y:Number=0,z:Number=0) { 7 this.x=x; 8 this.y=y; 9 this.z=z; 10 } 11 } 12 }
所以上面的问题也可以简化为:等角空间中3D坐标点,如何转换为电脑屏幕上的2D坐标点?(或者反过来转换?)
转化公式:
x1 = x - z
y1 = y * 1.2247 + (x + z) * 0.5
z2 = (x + z) * 0.866 - y * 0.707 --用于层深排序,可以先不管
上面的公式可以把等角空间中的坐标点,转化为屏幕空间上的坐标点。(好奇心强烈的童鞋们,自己去看原书上的推导过程吧,我建议大家把这它当成定理公式记住就好,毕竟我们不是在研究数学)
为了方便以后重用,可以把这个公式封装到类IsoUtil.as里
1 package { 2 3 import flash.geom.Point; 4 public class IsoUtils { 5 6 //public static const Y_CORRECT:Number=Math.cos(- Math.PI/6)*Math.SQRT2; 7 public static const Y_CORRECT:Number = 1.2247448713915892; 8 9 //把等角空间中的一个3D坐标点转换成屏幕上的2D坐标点 10 public static function isoToScreen(pos:Point3D):Point { 11 var screenX:Number=pos.x-pos.z; 12 var screenY:Number=pos.y*Y_CORRECT+(pos.x+pos.z)*0.5; 13 return new Point(screenX,screenY); 14 } 15 16 //把屏幕上的2D坐标点转换成等角空间中的一个3D坐标点 17 public static function screenToIso(point:Point):Point3D { 18 var xpos:Number=point.y+point.x*.5; 19 var ypos:Number=0; 20 var zpos:Number=point.y-point.x*.5; 21 return new Point3D(xpos,ypos,zpos); 22 } 23 } 24 }
用代码来画一个等角图形,测试上面的代码是否正确
1 package { 2 3 import flash.display.Sprite; 4 import flash.display.StageAlign; 5 import flash.display.StageScaleMode; 6 import flash.geom.Point; 7 8 [SWF(backgroundColor=0xefefef,height="200",width="300")] 9 public class IsoTransformTest extends Sprite { 10 public function IsoTransformTest() { 11 stage.align=StageAlign.TOP_LEFT; 12 stage.scaleMode=StageScaleMode.NO_SCALE; 13 14 var p0:Point3D=new Point3D(0,0,0); 15 var p1:Point3D=new Point3D(100,0,0); 16 var p2:Point3D=new Point3D(100,0,100); 17 var p3:Point3D=new Point3D(0,0,100); 18 19 var sp0:Point=IsoUtils.isoToScreen(p0); 20 var sp1:Point=IsoUtils.isoToScreen(p1); 21 var sp2:Point=IsoUtils.isoToScreen(p2); 22 var sp3:Point=IsoUtils.isoToScreen(p3); 23 24 var tile:Sprite = new Sprite(); 25 tile.x=150; 26 tile.y=50; 27 addChild(tile); 28 29 tile.graphics.lineStyle(0); 30 tile.graphics.moveTo(sp0.x, sp0.y); 31 tile.graphics.lineTo(sp1.x, sp1.y); 32 tile.graphics.lineTo(sp2.x, sp2.y); 33 tile.graphics.lineTo(sp3.x, sp3.y); 34 tile.graphics.lineTo(sp0.x, sp0.y); 35 36 trace(Math.cos(- Math.PI/6)*Math.SQRT2);//1.2247448713915892 37 trace(tile.width,tile.height);//200 100 符合上面提到的2:1 38 } 39 } 40 }
正如在OO世界里,很多语言都习惯于弄一个Object基类做为祖先一样,在等角世界里,我们也可以弄一个IsoObject的基类,把坐标变换这一套东西封装在里面,方便重用
1 package { 2 import flash.display.Sprite; 3 import flash.geom.Point; 4 import flash.geom.Rectangle; 5 6 public class IsoObject extends Sprite { 7 8 protected var _position:Point3D; 9 protected var _size:Number; 10 protected var _walkable:Boolean=false; 11 12 //public static const Y_CORRECT:Number=Math.cos(- Math.PI/6)*Math.SQRT2; 13 public static const Y_CORRECT:Number=1.2247448713915892; 14 15 public function IsoObject(size:Number) { 16 _size=size; 17 _position = new Point3D(); 18 updateScreenPosition(); 19 } 20 21 //更新屏幕坐标位置 22 protected function updateScreenPosition():void { 23 var screenPos:Point=IsoUtils.isoToScreen(_position); 24 super.x=screenPos.x; 25 super.y=screenPos.y; 26 } 27 28 override public function toString():String { 29 return "[IsoObject (x:" + _position.x + ", y:" + _position.y+ ", z:" + _position.z + ")]"; 30 } 31 32 //设置等角空间3D坐标点的x,y,z值 33 override public function set x(value:Number):void { 34 _position.x=value; 35 updateScreenPosition(); 36 } 37 38 override public function get x():Number { 39 return _position.x; 40 } 41 42 override public function set y(value:Number):void { 43 _position.y=value; 44 updateScreenPosition(); 45 } 46 override public function get y():Number { 47 return _position.y; 48 } 49 50 override public function set z(value:Number):void { 51 _position.z=value; 52 updateScreenPosition(); 53 } 54 55 override public function get z():Number { 56 return _position.z; 57 } 58 59 //_position的属性封装 60 public function set position(value:Point3D):void { 61 _position=value; 62 updateScreenPosition(); 63 } 64 public function get position():Point3D { 65 return _position; 66 } 67 68 //深度排序时会用到,现在不用理这个 69 public function get depth():Number { 70 return (_position.x + _position.z) * .866 - _position.y * .707; 71 } 72 73 //这个暂时也不用理 74 public function set walkable(value:Boolean):void { 75 _walkable=value; 76 } 77 public function get walkable():Boolean { 78 return _walkable; 79 } 80 81 public function get size():Number { 82 return _size; 83 } 84 85 public function get rect():Rectangle { 86 return new Rectangle(x - size / 2, z - size / 2, size, size); 87 } 88 } 89 }
接触过3D渲染或动画的朋友们也许都知道,通常人们习惯弄出一个最基本的三角形(或其它小形状)做为基本贴片,用这些小贴片最终构成复杂的3D模型,类似的,我们也可以做一个基本的IsoTile贴片类
1 package { 2 public class DrawnIsoTile extends IsoObject { 3 protected var _height:Number; 4 protected var _color:uint; 5 public function DrawnIsoTile(size:Number,color:uint,height:Number=0) { 6 super(size); 7 _color=color; 8 _height=height; 9 draw(); 10 } 11 12 //画矩形"贴片" 13 protected function draw():void { 14 graphics.clear(); 15 graphics.beginFill(_color); 16 graphics.lineStyle(0,0,.5); 17 graphics.moveTo(- size,0); 18 graphics.lineTo(0,- size*.5); 19 graphics.lineTo(size,0); 20 graphics.lineTo(0,size*.5); 21 graphics.lineTo(- size,0); 22 } 23 24 //height属性暂时不用管(在draw里也没用到) 25 override public function set height(value:Number):void { 26 _height=value; 27 draw(); 28 } 29 override public function get height():Number { 30 return _height; 31 } 32 33 //设置颜色 34 public function set color(value:uint):void { 35 _color=value; 36 draw(); 37 } 38 public function get color():uint { 39 return _color; 40 } 41 } 42 }
试一下IsoTile:
可以把这个当做游戏中的空白地图,ok,继续,光画一个平面,也许没什么意思,再考虑更复杂一些的物体:比如(立方体)盒子(其实基本思路不复杂,把贴片提高一些位置,然后向下伸出同等长度的线条,最终连接起来即可)。
在等角世界中,站在观察者的角度,一个立方体最终能看到的只有三个面(top,left,right),如果再加个光源的话,还应该体现出颜色的明暗度差别(比如如果一个光源从盒子的右上方照过来,上方应该是最亮的,右侧其次,左侧最暗)
1 package { 2 public class DrawnIsoBox extends DrawnIsoTile { 3 public function DrawnIsoBox(size:Number, color:uint, height:Number) { 4 super(size, color, height); 5 } 6 override protected function draw():void { 7 graphics.clear(); 8 9 //提取r,g,b三色分量 10 var red:int=_color>>16; 11 var green:int=_color>>8&0xff; 12 var blue:int=_color&0xff; 13 14 //假如光源在右上方(所以左侧最暗,顶上最亮,右侧在二者之间) 15 var leftShadow:uint = (red * .5) << 16 |(green * .5) << 8 |(blue * .5); 16 var rightShadow:uint = (red * .75) << 16 |(green * .75) << 8 | (blue * .75); 17 var h:Number=_height*Y_CORRECT; 18 19 //顶部 20 graphics.beginFill(_color); 21 graphics.lineStyle(0, 0, .5); 22 graphics.moveTo(-_size, -h); 23 graphics.lineTo(0, -_size * .5 - h); 24 graphics.lineTo(_size, -h); 25 graphics.lineTo(0, _size * .5 - h); 26 graphics.lineTo(-_size, -h); 27 graphics.endFill(); 28 29 //左侧 30 graphics.beginFill(leftShadow); 31 graphics.lineStyle(0, 0, .5); 32 graphics.moveTo(-_size, -h); 33 graphics.lineTo(0, _size * .5 - h); 34 graphics.lineTo(0, _size * .5); 35 graphics.lineTo(-_size, 0); 36 graphics.lineTo(-_size, -h); 37 graphics.endFill(); 38 39 //右侧 40 graphics.beginFill(rightShadow); 41 graphics.lineStyle(0, 0, .5); 42 graphics.moveTo(_size, -h); 43 graphics.lineTo(0, _size * .5 - h); 44 graphics.lineTo(0, _size * .5); 45 graphics.lineTo(_size, 0); 46 graphics.lineTo(_size, -h); 47 graphics.endFill(); 48 } 49 } 50 }
测试一下IsoBox
1 package { 2 3 4 import flash.display.Sprite; 5 import flash.display.StageAlign; 6 import flash.display.StageScaleMode; 7 import flash.events.*; 8 import flash.events.MouseEvent; 9 import flash.geom.Point; 10 [SWF(backgroundColor=0xffffff,height=380,width=600)] 11 public class BoxTest extends Sprite 12 { 13 private var world:Sprite; 14 15 public function BoxTest() 16 { 17 stage.align = StageAlign.TOP_LEFT; 18 stage.scaleMode = StageScaleMode.NO_SCALE; 19 world = new Sprite(); 20 world.x = stage.stageWidth / 2; 21 world.y = 50; 22 addChild(world); 23 for(var i:int = 0; i < 15; i++) 24 { 25 for(var j:int = 0; j < 15; j++) 26 { 27 var tile:DrawnIsoTile = new DrawnIsoTile(20, 0xcccccc); 28 tile.position = new Point3D(i * 20, 0, j * 20); 29 world.addChild(tile); 30 } 31 } 32 world.addEventListener(MouseEvent.CLICK, onWorldClick); 33 stage.addEventListener(Event.RESIZE,resizeHandler); 34 } 35 36 private function onWorldClick(event:MouseEvent):void 37 { 38 var box:DrawnIsoBox = new DrawnIsoBox(20, Math.random() * 0xffffff, 20); 39 var pos:Point3D = IsoUtils.screenToIso(new Point(world.mouseX, world.mouseY)); 40 pos.x = Math.round(pos.x / 20) * 20; 41 pos.y = Math.round(pos.y / 20) * 20; 42 pos.z = Math.round(pos.z / 20) * 20; 43 box.position = pos; 44 world.addChild(box); 45 } 46 47 private function resizeHandler(e:Event):void{ 48 world.x = stage.stageWidth / 2; 49 } 50 } 51 }
稍加解释,上面这段代码先画一个空白地图,然后在地图上注册鼠标点击事件,每次点击将在地图上生成一个IsoBox实例