事情的起因
之前写了篇谈谈文字图片粒子化 I,并且写了个简单的demo -> 粒子化。正当我在为写 谈谈文字图片粒子化II 准备demo时,突然想到能不能用正方体代替demo中的球体粒子。我不禁被自己的想法吓了一跳,球体的实现仅仅是简单的画圆,因为球体在任意角度任意距离的视图都是圆(如果有视图的话);而正方体有6个面8个点12条线,在canvas上的渲染多了n个数量级。先不说性能的问题,单单要实现六个面的旋转和绘制就不是一件特别容易的事情。
说干就干,经过曲折的过程,终于得到了一个半成品 -> 粒子化之正方体
事情的经过
事情的经过绝不像得到的结果那样简单。虽然半成品demo在视觉上还有些许违和感,但已经能基本上达到我对粒子化特效的要求了。
那么接下来说说我这次的蛋疼经历吧。
之前我们已经实现了一个点在三维系的坐标转换(如不懂,可参考 rotate 3d I),并且得到了这样的一个demo -> 3d球体。 那么我想,既然能得到点在三维系的空间转换坐标,根据点-线-面的原理,理论上应该很容易实现正方体在三维系的体现,不就是初始化相对位置一定的8个点么?而且之前也简单地实现了一个面的demo -> 3d爱心,当时认为并不难。
于是我根据一定的相对位置,在三维系中初始化了8个点,每帧渲染的同时实现8个点的位置转移,并且根据8个点的位置每帧重绘12条线,得到demo -> 3d正方体
似乎很顺利,接着给6个面上色,效果图如下:
这时我意识到应该是面的绘制顺序出错了,在每帧的绘制前应该先给面排个序,比如图示的正方体的体心是三维系的原点,那么正方体的后面肯定是不可见的,所以应该先绘制。而在制作三维球体旋转时,是根据球体中心在三维系的坐标z值排序的,这一点也很好理解,越远的越容易被挡就越先画嘛;同时我在WAxes的这篇用Canvas玩3D:点-线-面中看到他绘制正方体的方法是根据6个面中心点的z值进行排序,乍一想似乎理所当然,于是我去实现了,体心在原点体验良好,demo -> 3d正方体,但是体心一改变位置,就坑爹了...
图示的正方体体心在原点的右侧(沿x轴正方向),但是画出来的正方体却有违和感,为何?接着我还原了绘制的过程:
绘制过程先绘制了正方体的左面,再绘制了上面,而根据生活经验这两个面的绘制顺序应该是先上面,再左面!不断的寻找错误,我发现这两个面中点的z值是一样的,甚至除了前后两个面,其他的四个面的z值都是一样的,也就是说这个例子中后面最先绘,前面最后绘,其他四个面的绘制顺序是任意的。我继续朝着这个方向前进,根据我的生活经验,如果像上图一样体心在原点右边(其实应该是视点,当时认为是原点),那么如果面的z值相同,应该根据面与原点的x方向的距离进行排序,毕竟距离小的先看到,如果x方向距离又相同,那么根据y方向的距离进行排序,代码如下:
var that = this;this.f.sort(function (a, b) { if(b.zIndex !== a.zIndex) return b.zIndex - a.zIndex; else if(b.xIndex !== a.xIndex) { // 观察基准点(0,0,0) if(that.x >= 0) return b.xIndex - a.xIndex; else return a.xIndex - b.xIndex; } else { if(that.y >= 0) return b.yIndex - a.yIndex; else return a.yIndex - b.yIndex; }
因为排序中this指向了window,还需赋值给一个另外的变量保存。事情似乎在此能画上一个圆满的句号,but...
调整后继续出现违和感(截图如下),虽然违和感的体验就在那么一瞬,但是我还是觉得是不是这个排序思路出错了?于是进一步验证,通过调试,将面的排序结果和正确的绘制顺序作对比,最终发现排序算法是错误的,最后知道真相的我眼泪掉下来。
于是在知乎上问了下:怎样在二维上确定一个三维空间正方体六个面的绘制顺序? 有计算机图形学基础的请无视。
原来这是一个古老的问题,在各位图形学大大的眼里是很基础的问题了。原来这个问题称为隐藏表面消除问题。
然后我跟着这个方法进行了绘制,一开始把视点和原点搞混掉了。也就是判断每个面的法向量(不取指向体心的那条)和面(近似取面中心)到视点的那条向量之间的角度,如果小于90度则是可见。想了一下,似乎还真是那么一回事。然后需要设定视点的坐标,随意设置,只要合乎常理就行,这里我设置了(0,0,-500),在z方向肯定是个负值。
一个正方体差不多搞定了,多个正方体呢?问题又出现:
很显然,正方体之间也有绘制的先后顺序,这里粗略地采用根据体心排序的方法,按照Milo Yip的说法,这可以解决大部分情况,但也会漏掉一些最坏情况。最好的做法是zbuffer算法。
于是乎,一个多正方体demo新鲜出炉了-> 多正方体demo
如果要打造 粒子化之正方体 的效果,参考-> 谈谈文字图片粒子化 I
这里我设置了场景(Garden)、正方体(Cube)、面(Face)、点(Ball)四个类。
梳理一下多个正方体具体渲染过程:
- 先将正方体进行排序,确定正方体的绘制顺序
- 接着渲染每个正方体,先渲染正方体的各个点,改变各个点最新的坐标
for(var i = 0; i < 8; i++) this.p[i].render();
- 点渲染完后,根据最新的点的坐标调整正方体体心坐标,为下一帧的正方体排序准备
this.changeCoordinate();
- 获取每个面法向量和面中点和视点夹角cos值,如果大于0(夹角小于90)则绘制(这里其实不用排序):
for(var i = 0; i < 6; i++) this.f[i].angle = this.f[i].getAngle();this.f.sort(function (a, b) { return a.angle > b.angle;});for(var i = 0; i < 6; i++) { // 夹角 < 90,绘制 if(this.f[i].angle > 0) this.f[i].draw();}
- 反复渲染
完整代码如下:
1 <!DOCTYPE html> 2 <html> 3 <head> 4 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 5 <title> rotate 3d</title> 6 <script> 7 window.onload = function() { 8 var canvas = document.getElementById('canvas'); 9 var ctx = canvas.getContext('2d'); 10 var img = document.getElementById('img1'); 11 ctx.drawImage(img, 0, 0); 12 var data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; 13 ctx.clearRect(0, 0, canvas.width, canvas.height); 14 var length = data.length; 15 var num = 0; 16 var textPoint = []; 17 var r = 5; 18 var offsetX = -130; 19 var offsetY = -170; 20 for (var i = 0, wl = canvas.width * 4; i < length; i += 4) { 21 if (data[i + 3]) { 22 var x = (i % wl) / 4; 23 var y = parseInt(i / wl) 24 num++; 25 textPoint.push([offsetX + x * r * 2, offsetY + y * r * 2]); 26 } 27 } 28 29 var garden = new Garden(canvas); 30 31 // 设置二维视角原点(一般为画布中心) 32 garden.setBasePoint(500, 250); 33 for(var i = 0; i < textPoint.length; i++) 34 garden.createCube(textPoint[i][0], textPoint[i][1], 0, r - 1); 35 36 // 构造 37 // var z = 20; 38 // garden.createCube(0, 0, z, 30); 39 // garden.createCube(60, 0, z, 20); 40 // garden.createCube(-60, 0, z, 20); 41 42 // garden.createCube(0, 60, z, 20); 43 // garden.createCube(60, 60, z, 20); 44 // garden.createCube(-60, 60, z, 20); 45 // garden.createCube(60, -60, z, 20); 46 // garden.createCube(0, -60, z, 20); 47 48 // garden.createCube(-60, -60, z, 20); 49 50 51 // 设置监听 52 // garden.setListener(); 53 54 // 渲染 55 setInterval(function() {garden.render();}, 1000 / 60); 56 }; 57 58 function Garden(canvas) { 59 this.canvas = canvas; 60 this.ctx = this.canvas.getContext('2d'); 61 62 // 三维系在二维上的原点 63 this.vpx = undefined; 64 this.vpy = undefined; 65 this.cubes = []; 66 this.angleY = Math.PI / 180 * 1; 67 this.angleX = Math.PI / 180 * 1; 68 } 69 70 Garden.prototype = { 71 setBasePoint: function(x, y) { 72 this.vpx = x; 73 this.vpy = y; 74 }, 75 76 createCube: function(x, y, z, r) { 77 this.cubes.push(new Cube(this, x, y, z, r)); 78 }, 79 80 render: function() { 81 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); 82 var that = this; 83 this.cubes.sort(function (a, b) { 84 if(b.z !== a.z) 85 return b.z - a.z; 86 else if(b.xIndex !== a.xIndex) { 87 if(that.x >= 0) 88 return b.x - a.x; 89 else 90 return a.x - b.x; 91 } else { 92 if(that.y >= 0) 93 return b.y - a.y; 94 else 95 return a.y - b.y; 96 } 97 }); 98 99 for(var i = 0; i < this.cubes.length; i++) 100 this.cubes[i].render();101 }102 103 // setListener: function() {104 // var that = this;105 // document.addEventListener('mousemove', function(event){106 // var x = event.clientX - that.vpx;107 // var y = event.clientY - that.vpy;108 // that.angleY = -x * 0.0001;109 // that.angleX = y * 0.0001;110 // });111 // }112 };113 114 function Ball(cube, x, y, z) {115 this.cube = cube;116 117 // 三维上坐标118 this.x = x;119 this.y = y;120 this.z = z;121 122 // 二维上坐标123 this.x2 = undefined;124 this.y2 = undefined;125 }126 127 Ball.prototype = {128 // 绕y轴变化,得出新的x,z坐标129 rotateY: function() {130 var cosy = Math.cos(this.cube.angleY);131 var siny = Math.sin(this.cube.angleY);132 var x1 = this.z * siny + this.x * cosy;133 var z1 = this.z * cosy - this.x * siny;134 this.x = x1;135 this.z = z1;136 },137 138 // 绕x轴变化,得出新的y,z坐标139 rotateX: function() {140 var cosx = Math.cos(this.cube.angleX);141 var sinx = Math.sin(this.cube.angleX);142 var y1 = this.y * cosx - this.z * sinx;143 var z1 = this.y * sinx + this.z * cosx;144 this.y = y1;145 this.z = z1;146 },147 148 getPositionInTwoDimensionalSystem: function(a) {149 // focalLength 表示当前焦距,一般可设为一个常量150 var focalLength = 300; 151 // 把z方向扁平化152 var scale = focalLength / (focalLength + this.z);153 this.x2 = this.cube.garden.vpx + this.x * scale;154 this.y2 = this.cube.garden.vpy + this.y * scale;155 },156 157 render: function() {158 this.rotateX();159 this.rotateY();160 this.getPositionInTwoDimensionalSystem();161 }162 };163 164 function Cube(garden, x, y, z, r) {165 this.garden = garden;166 167 // 正方体中心和半径168 this.x = x;169 this.y = y;170 this.z = z;171 this.r = r;172 173 this.angleX = Math.PI / 180 * 1;174 this.angleY = Math.PI / 180 * 1;175 176 // cube的8个点177 this.p = [];178 179 // cube的6个面180 this.f = [];181 182 this.init();183 }184 185 Cube.prototype = {186 init: function() {187 // 正方体的每个顶点都是一个ball类实现188 this.p[0] = new Ball(this, this.x - this.r, this.y - this.r, this.z - this.r);189 this.p[1] = new Ball(this, this.x - this.r, this.y + this.r, this.z - this.r);190 this.p[2] = new Ball(this, this.x + this.r, this.y + this.r, this.z - this.r);191 this.p[3] = new Ball(this, this.x + this.r, this.y - this.r, this.z - this.r);192 this.p[4] = new Ball(this, this.x - this.r, this.y - this.r, this.z + this.r);193 this.p[5] = new Ball(this, this.x - this.r, this.y + this.r, this.z + this.r);194 this.p[6] = new Ball(this, this.x + this.r, this.y + this.r, this.z + this.r);195 this.p[7] = new Ball(this, this.x + this.r, this.y - this.r, this.z + this.r);196 197 // 正方体6个面198 this.f[0] = new Face(this, this.p[0], this.p[1], this.p[2], this.p[3]);199 this.f[1] = new Face(this, this.p[3], this.p[2], this.p[6], this.p[7]);200 this.f[2] = new Face(this, this.p[4], this.p[5], this.p[6], this.p[7]);201 this.f[3] = new Face(this, this.p[4], this.p[5], this.p[1], this.p[0]);202 this.f[4] = new Face(this, this.p[0], this.p[3], this.p[7], this.p[4]);203 this.f[5] = new Face(this, this.p[5], this.p[1], this.p[2], this.p[6]);204 },205 206 render: function() {207 for(var i = 0; i < 8; i++) 208 this.p[i].render();209 210 // 八个点的坐标改变完后,改变cube体心坐标,为下一帧cube的排序作准备211 this.changeCoordinate();212 213 for(var i = 0; i < 6; i++)214 this.f[i].angle = this.f[i].getAngle();215 216 // 不是必须217 this.f.sort(function (a, b) {218 return a.angle > b.angle;219 });220 221 for(var i = 0; i < 6; i++) {222 // 夹角 < 90,绘制223 if(this.f[i].angle > 0)224 this.f[i].draw();225 }226 },227 228 // cube体心坐标改变229 changeCoordinate: function() {230 this.x = this.y = this.z = 0;231 for(var i = 0; i < 8; i++) {232 this.x += this.p[i].x;233 this.y += this.p[i].y;234 this.z += this.p[i].z;235 }236 this.x /= 8;237 this.y /= 8;238 this.z /= 8;239 }240 };241 242 function Face(cube, a, b, c, d) {243 this.cube = cube;244 this.a = a;245 this.b = b;246 this.c = c;247 this.d = d;248 this.color = '#' + ('00000' + parseInt(Math.random() * 0xffffff).toString(16)).slice(-6);249 // 面的法向量和面心到视点向量的夹角的cos值250 this.angle = undefined;251 }252 253 Face.prototype = {254 draw: function() {255 var ctx = this.cube.garden.ctx;256 ctx.beginPath();257 ctx.fillStyle = this.color;258 ctx.moveTo(this.a.x2, this.a.y2);259 ctx.lineTo(this.b.x2, this.b.y2);260 ctx.lineTo(this.c.x2, this.c.y2);261 ctx.lineTo(this.d.x2, this.d.y2);262 ctx.closePath();263 ctx.fill();264 },265 266 // 获取面的法向量和z轴夹角267 getAngle: function() {268 var x = (this.a.x + this.b.x + this.c.x + this.d.x) / 4 - this.cube.x;269 var y = (this.a.y + this.b.y + this.c.y + this.d.y) / 4 - this.cube.y;270 var z = (this.a.z + this.b.z + this.c.z + this.d.z) / 4 - this.cube.z;271 // 面的法向量272 var v = new Vector(x, y, z);273 274 // 视点设为(0,0,-500)275 var x = 0 - (this.a.x + this.b.x + this.c.x + this.d.x) / 4;276 var y = 0 - (this.a.y + this.b.y + this.c.y + this.d.y) / 4;277 var z = - 500 - (this.a.z + this.b.z + this.c.z + this.d.z) / 4;278 // 面心指向视点的向量279 var v2 = new Vector(x, y, z);280 return v.dot(v2);281 }282 }; 283 284 function Vector(x, y, z) {285 this.x = x;286 this.y = y;287 this.z = z;288 } 289 290 // 向量点积,大于0为0~90度291 Vector.prototype.dot = function(v) {292 return this.x * v.x + this.y * v.y + this.z * v.z;293 }294 295 </script>296 </head>297 <body bgcolor='#000'> 298 <img id="img1" style="display:none" src="2.png" />299 <canvas id='canvas' width=1000 height=600 style='background-color:#000'>300 This browser does not support html5.301 </canvas>302 </body>303 </html>
事情的结果
事情似乎得到了一个较为满意的结果。
事实上,因为canvas暂时只支持2d,所以3d的渲染如果要得到最好的效果还是要使用webGL,但是这个思考的过程还是很重要的。
That's all.