网页小实验——用canvas生成精灵动画图片
实验目标:借助canvas把一张国际象棋棋子图片转换为一组适用于WebGL渲染的精灵动画图片,不借助其他图片处理工具,不引用其他库只使用原生js实现。
初始图片如下:
一、图片分割
将初始图片分割为六张大小相同的棋子图片
1、html舞台:
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>处理棋子图片</title> 6 </head> 7 <body> 8 <canvas id="can_source" style="z-index: 1;top:2px;left:2px;position: absolute"></canvas><!--显示原图的画布--> 9 <canvas id="can_mask" style="z-index: 10;top:2px;left:2px;position: absolute"></canvas><!--显示操作范围提示的画布--> 10 <canvas id="can_maskbak" style="z-index: 1;top:2px;left:2px;position: absolute"></canvas><!--用来划分区域的背景画布--> 11 </body> 12 <script><!--主体代码--> 13 </script> 14 </html>
这里准备了三张canvas画布,其中can_source是预览原图的画布,称为“源画布”;can_mask是悬浮在can_source上层的透明背景画布,用来绘制切割范围提示,称为“提示画布”;can_maskbak用来圈定切割范围(其实可以不显示它),称为“范围画布”。
2、分割流程:
1 var can_source=document.getElementById("can_source"); 2 var can_mask=document.getElementById("can_mask"); 3 var can_maskbak=document.getElementById("can_maskbak"); 4 var top_res; 5 var width=0,height=0; 6 window.onload=function(){ 7 var img=new Image(); 8 img.src="../../ASSETS/IMAGE/ICON/chesses.jpg"; 9 img.onload=function(){ 10 width=img.width;//根据图片尺寸设置画布尺寸 11 height=img.height; 12 can_source.style.width=width+"px";//css尺寸 13 can_source.style.height=height+"px"; 14 can_source.width=width;//canvas像素尺寸 15 can_source.height=height; 16 var con_source=can_source.getContext("2d"); 17 con_source.drawImage(img,0,0);//显示原图 18 19 top_res=height+4+"px"; 20 can_maskbak.style.left=width+4+"px";//把这个圈定范围的画布放在右边,做对比 21 can_maskbak.style.width=width+"px"; 22 can_maskbak.style.height=height+"px"; 23 can_maskbak.width=width; 24 can_maskbak.height=height; 25 var con_maskbak=can_maskbak.getContext("2d"); 26 con_maskbak.fillStyle="rgba(0,0,0,1)";//填充完全不透明的黑色 27 con_maskbak.fillRect(0,0,width,height); 28 29 can_mask.style.width=width+"px"; 30 can_mask.style.height=height+"px"; 31 can_mask.width=width; 32 can_mask.height=height; 33 var con_mask=can_mask.getContext("2d"); 34 con_mask.fillStyle="rgba(0,0,0,0)"; 35 con_mask.fillRect(0,0,width,height); 36 //下面是具体的操作代码 37 //cutRect(40,10,120,240,256,256);//矩形切割 38 //cutRect(192,10,120,240,256,256); 39 //cutRect(340,10,120,240,256,256); 40 cutRect(33,241,120,240,256,256); 41 cutRect(200,241,120,240,256,256); 42 cutRect(353,241,120,240,256,256); 43 } 44 }
3、具体切割算法:
1 //从一个画布上下文中剪切一块dataUrl 2 function cutRect(x,y,wid,hig,wid2,hig2) 3 { 4 //将矩形转换为路径,然后用更一般化的路径方法处理区域 5 var path=[{x:x,y:y},{x:x+wid,y:y},{x:x+wid,y:y+hig},{x:x,y:y+hig}]; 6 var framearea=[x,y,wid,hig];//framearea是操作范围的边界,矩形切割则直接是矩形本身,多边形切割则应是多边形的外切矩形范围 7 cutPath(path,framearea,wid2,hig2); 8 9 } 10 function cutPath(path,framearea,wid2,hig2) 11 { 12 var len=path.length; 13 var con_mask=can_mask.getContext("2d"); 14 con_mask.strokeStyle="rgba(160,197,232,1)";//线框 15 con_mask.beginPath(); 16 for(var i=0;i<len;i++) 17 { 18 var point=path[i]; 19 if(i==0) 20 { 21 con_mask.moveTo(point.x,point.y); 22 } 23 else { 24 con_mask.lineTo(point.x,point.y); 25 } 26 27 } 28 con_mask.closePath();//在提示画布中绘制提示框 29 con_mask.stroke(); 30 //con_mask.Path; 31 32 33 var con_maskbak=can_maskbak.getContext("2d"); 34 con_maskbak.beginPath(); 35 con_maskbak.fillStyle="rgba(0,255,0,1)"; 36 con_maskbak.lineWidth=0; 37 for(var i=0;i<len;i++) 38 { 39 var point=path[i]; 40 con_maskbak.lineTo(point.x,point.y); 41 } 42 con_maskbak.closePath(); 43 con_maskbak.fill();//在范围画布中画出切割的范围(纯绿色) 44 45 var con_source=can_source.getContext("2d"); 46 var data_source=con_source.getImageData(framearea[0],framearea[1],framearea[2],framearea[3]);//获取源画布在操作范围内的像素 47 var data_maskbak=con_maskbak.getImageData(framearea[0],framearea[1],framearea[2],framearea[3]);//获取范围画布在操作范围内的像素 48 49 var can_temp=document.createElement("canvas");//建立一个暂存canvas作为工具,并不实际显示它。 50 can_temp.width=wid2||framearea[2];//设置暂存画布的尺寸,这里要把长方形的切图保存为正方形! 51 can_temp.height=hig2||framearea[3]; 52 var con_temp=can_temp.getContext("2d"); 53 con_temp.fillStyle="rgba(255,255,255,1)"; 54 con_temp.fillRect(0,0,can_temp.width,can_temp.height); 55 var data_res=con_temp.createImageData(framearea[2],framearea[3]);//建立暂存画布大小的像素数据 56 57 58 var len=data_maskbak.data.length; 59 for(var i=0;i<len;i+=4)//对于范围画布的每一个像素 60 { 61 if(data_maskbak.data[i+1]=255)//如果这个像素是绿色 62 { 63 data_res.data[i]=(data_source.data[i]);//则填充源画布的对应像素 64 data_res.data[i+1]=(data_source.data[i+1]); 65 data_res.data[i+2]=(data_source.data[i+2]); 66 data_res.data[i+3]=(data_source.data[i+3]); 67 } 68 else 69 { 70 data_res.data[i]=(255);//否则填充完全不透明的白色,注意不透明度通道在rgba表示中是0到1,在data表示中是0到255! 71 data_res.data[i+1]=(255); 72 data_res.data[i+2]=(255); 73 data_res.data[i+3]=(255); 74 } 75 } 76 con_temp.putImageData(data_res,(can_temp.width-framearea[2])/2,(can_temp.height-framearea[3])/2)//把填充完毕的像素数据放置在暂存画布的中间 77 console.log(can_temp.toDataURL());//以dataUrl方式输出暂存画布的数据 78 79 }
4、切割效果如下:
在控制台里可以找到以文本方式输出的图片数据:
对于小于2MB的图片数据,直接复制dataUrl粘贴到浏览器地址栏回车,即可显示完整图片,之后右键保存;对于大于2MB的图片数据则需把can_temp显示出来,之后右键保存。精灵动画的单帧图片一般较小,所以不考虑需要显示can_temp的情况。
最终获取的一张“兵”图片:
5、改进
其实canvas的path对象本身就有clip方法,可以用这个内置方法简化以上过程。
clip方法的文档:https://www.w3school.com.cn/tags/canvas_clip.asp
二、生成精灵动画
1、html舞台及准备代码:
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>建立棋子的动画帧,添加一个图标样式</title> 6 </head> 7 <body> 8 <canvas id="can_back" style="z-index: 1;top:2px;left:2px;position: absolute"></canvas><!--徽章的背景--> 9 <canvas id="can_back2" style="z-index: 1;top:2px;left:2px;position: absolute"></canvas> 10 <canvas id="can_res" style="z-index: 1;top:2px;left:2px;position: absolute"></canvas><!--显示结果--> 11 </body> 12 <script> 13 var can_back=document.getElementById("can_back"); 14 var can_back2=document.getElementById("can_back2"); 15 var can_res=document.getElementById("can_res"); 16 var width=240,height=360; 17 window.onload=function(){ 18 console.log("程序开始") 19 can_back.style.width=width+"px"; 20 can_back.width=width; 21 can_back.style.height=height+"px"; 22 can_back.height=height; 23 can_back2.style.width=width+"px"; 24 can_back2.width=width; 25 can_back2.style.height=height+"px"; 26 can_back2.height=height; 27 can_back2.style.left=width+4+"px"; 28 can_res.style.top=height+4+"px"; 29 var img=new Image(); 30 img.src="../../ASSETS/IMAGE/ICON/bing.png";//256*256的图片 31 img.onload=function(){//动画帧生成代码 32 } 33 </script> 34 </html>
2、在can_back中为棋子添加“徽章”背景
添加后效果如下:
为棋子添加了一个形状和颜色渐变的徽章背景,徽章外为透明色,可以根据棋子所属的势力为徽章设置不同的主色调。算法首先判断can_back的像素点是否在棋子“兵”内,如果在棋子内则原样呈现,否则根据像素的位置计算像素的颜色,一种实现方法如下:
1 var con_back=can_back.getContext("2d"); 2 con_back.fillStyle="rgba(0,255,0,0)"; 3 con_back.fillRect(0,0,width,height); 4 con_back.drawImage(img,(width-256)/2,(height-256)/2) 5 6 var data_back=con_back.getImageData(0,0,width,height); 7 //var len=data_back.length; 8 var r1=22/19; 9 var r2=22/51; 10 var p_light=255;//背景强度 11 var i_min=0,i_max=0; 12 //一行一行地处理像素 13 var data=data_back.data; 14 for(var i=0;i<360;i++) 15 { 16 var num_w=(Math.pow(110*110-(i-100)*(i-100)*r2*r2,0.5)); 17 for(var j=0;j<240;j++)//对于这一行里的每一个像素 18 { 19 var index=(i*240+j)*4; 20 if(i<5||i>355) 21 { 22 data[index]=0; 23 data[index+1]=255; 24 data[index+2]=0; 25 data[index+3]=0; 26 } 27 else 28 { 29 30 if(i<100) 31 { 32 if(Math.abs(j-119.5)<((i-5)*r1)) 33 { 34 if(data[index]+data[index+1]+data[index+2]>600||data[index+3]==0)//不是黑色或者完全透明 35 { 36 var b=127+128*(95-(i-5))/95;//保持红色为主色调 37 var b2=(p_light-b)/2; 38 data[index]=b; 39 data[index+1]=b2; 40 data[index+2]=b2; 41 data[index+3]=255; 42 } 43 else 44 { 45 data[index]=0; 46 data[index+1]=0; 47 data[index+2]=0; 48 data[index+3]=255; 49 if(i_min==0) 50 { 51 i_min=i; 52 i_max=i; 53 } 54 else 55 { 56 if(i>i_max) 57 { 58 i_max=i; 59 } 60 } 61 } 62 } 63 else 64 { 65 data[index]=0; 66 data[index+1]=255; 67 data[index+2]=0; 68 data[index+3]=0; 69 } 70 } 71 else 72 { 73 //if(Math.abs(j-119.5)<Math.pow((355-i),0.5)*r2) 74 if(Math.abs(j-119.5)<num_w) 75 { 76 if(data[index]+data[index+1]+data[index+2]>600||data[index+3]==0)//不是黑色 77 { 78 var b=127+128*(255-(355-i))/255; 79 var b2=(p_light-b)/2; 80 data[index]=b; 81 data[index+1]=b2; 82 data[index+2]=b2; 83 data[index+3]=255; 84 } 85 else 86 { 87 data[index]=0; 88 data[index+1]=0; 89 data[index+2]=0; 90 data[index+3]=255; 91 if(i_min==0) 92 { 93 i_min=i; 94 i_max=i; 95 } 96 else 97 { 98 if(i>i_max) 99 { 100 i_max=i; 101 } 102 } 103 } 104 } 105 else 106 { 107 data[index]=0; 108 data[index+1]=255; 109 data[index+2]=0; 110 data[index+3]=0; 111 } 112 } 113 } 114 } 115 } 116 con_back.putImageData(data_back,0,0);
3、在can_back2为徽章中的棋子描边
为后面的环节做准备,给棋子的轮廓描一层rgb(1,1,1)颜色、2px宽度的边
1 var size_border=2; 2 var rgb_border={r:1,g:1,b:1}; 3 if(size_border>0)//为前景和背景的边界描边的算法? 4 {//-》为特定的两种颜色边界描边的算法!!!! 5 console.log("开始描绘边界"); 6 drawBorder(data,240,360,isColorOut,isColorIn,Math.floor(size_border/2),size_border,rgb_border); 7 }//参数:像素数据,宽度,高度,判断像素在描边内测的条件,判断像素在描边外侧的条件,描边的偏移,边宽,描边的颜色 8 var con_back2=can_back2.getContext("2d"); 9 con_back2.putImageData(data_back,0,0);
描边函数:
1 function isColorOut(rgba) 2 { 3 if(rgba.r>127) 4 { 5 return true; 6 } 7 return false; 8 } 9 function isColorIn(rgba) 10 { 11 if(rgba.r==0&&rgba.g==0&&rgba.b==0) 12 { 13 return true; 14 } 15 return false; 16 } 17 //参数:像素数据,图片的宽度,图片的高度,”外部“的颜色(可以有多种),“内部的颜色”(可以有多种,但不应与arr_rgba1重复!!) 18 // ,决定把边画在内部还是外部的偏移(默认为0,画在中间?为正表示向内偏),边的宽度,边的颜色(认为完全不透明) 19 //使用xy的垂直遍历方法,另一种思路是让计算核沿着分界线移动《-绘制的更为平滑 20 //function drawBorder(data,width,height,arr_rgbaout,arr_rgbain,offset_inout,size_border,rgb_border) 21 //内外的颜色可能是渐变的!!所以在这里用返回布尔值的函数做参数!!!!而非固定颜色范围 22 function drawBorder(data,width,height,func_out,func_in,offset_inout,size_border,rgb_border) 23 { 24 //首先对于每一行像素 25 for(var i=0;i<height;i++) 26 { 27 var lastRGBA={}; 28 for(var j=0;j<width;j++) 29 { 30 var index=(i*240+j)*4; 31 var RGBA={r:data[index],g:data[index+1],b:data[index+2],a:data[index+3]}; 32 //if(!lastRGBA.r&&lastRGBA.r!=0)//如果是第一个像素 33 if(j==0) 34 { 35 lastRGBA=RGBA;//上一颜色 36 continue; 37 } 38 else 39 { 40 //if(isRGBAinArr(arr_rgbaout,lastRGBA)&&isRGBAinArr(arr_rgbain,RGBA))//在内外颜色的分界处(左侧) 41 if(func_out(lastRGBA)&&func_in(RGBA))//如果上一颜色应该在描边的外侧,同时当前颜色在描边的内侧 42 { 43 var os_left=Math.floor(size_border/2);//偏右 44 var os_right=size_border-os_left; 45 var j_left=j-os_left; 46 var j_right=j+os_right; 47 j_left+=offset_inout; 48 j_right+=offset_inout; 49 for(var k=j_left;k<j_right;k++)//修正偏右 50 { 51 if(k>=0&&k<width) 52 { 53 var index2=(i*240+k)*4; 54 data[index2]=rgb_border.r; 55 data[index2+1]=rgb_border.g; 56 data[index2+2]=rgb_border.b; 57 data[index2+3]=255; 58 } 59 60 } 61 } 62 //else if(isRGBAinArr(arr_rgbaout,RGBA)&&isRGBAinArr(arr_rgbain,lastRGBA))//在内外颜色的分界处(右侧) 63 else if(func_out(RGBA)&&func_in(lastRGBA)) 64 { 65 var os_right=Math.floor(size_border/2);//偏左 66 var os_left=size_border-os_right; 67 var j_left=j-os_left; 68 var j_right=j+os_right; 69 j_left-=offset_inout; 70 j_right-=offset_inout; 71 for(var k=j_left+1;k<=j_right;k++)//修正偏左 72 { 73 if(k>=0&&k<width) 74 { 75 var index2 = (i * 240 + k) * 4; 76 data[index2] = rgb_border.r; 77 data[index2 + 1] = rgb_border.g; 78 data[index2 + 2] = rgb_border.b; 79 data[index2 + 3] = 255; 80 } 81 } 82 } 83 } 84 lastRGBA=RGBA; 85 } 86 87 } 88 //然后对于每一列像素 89 for(var i=0;i<width;i++) 90 { 91 var lastRGBA={}; 92 for(var j=0;j<height;j++)//对于这一列中的每个像素 93 { 94 var index=(j*240+i)*4; 95 var RGBA={r:data[index],g:data[index+1],b:data[index+2],a:data[index+3]}; 96 //if(!lastRGBA.r&&lastRGBA.r!=0)//如果是第一个像素 97 if(j==0) 98 { 99 lastRGBA=RGBA; 100 continue; 101 } 102 else 103 { 104 //if(isRGBAinArr(arr_rgbaout,lastRGBA)&&isRGBAinArr(arr_rgbain,RGBA))//在内外颜色的分界处(左侧) 105 if(func_out(lastRGBA)&&func_in(RGBA)) 106 { 107 var os_up=Math.floor(size_border/2);//偏下 108 var os_down=size_border-os_up; 109 var j_up=j-os_down; 110 var j_down=j+os_right; 111 j_up+=offset_inout; 112 j_down+=offset_inout; 113 for(var k=j_up;k<j_down;k++)//不修正偏下 114 { 115 if(k>=0&&k<height) 116 { 117 var index2=(k*240+i)*4; 118 data[index2]=rgb_border.r; 119 data[index2+1]=rgb_border.g; 120 data[index2+2]=rgb_border.b; 121 data[index2+3]=255; 122 } 123 124 } 125 } 126 //else if(isRGBAinArr(arr_rgbaout,RGBA)&&isRGBAinArr(arr_rgbain,lastRGBA))//在内外颜色的分界处(右侧) 127 else if(func_out(RGBA)&&func_in(lastRGBA)) 128 {//下面应该是忘了改变量名 129 var os_right=Math.floor(size_border/2);//偏左 130 var os_left=size_border-os_right; 131 var j_left=j-os_left; 132 var j_right=j+os_right; 133 j_left-=offset_inout; 134 j_right-=offset_inout; 135 for(var k=j_left;k<j_right;k++)//修正偏左 136 { 137 if(k>=0&&k<height) 138 { 139 var index2 = (k * 240 + i) * 4; 140 data[index2] = rgb_border.r; 141 data[index2 + 1] = rgb_border.g; 142 data[index2 + 2] = rgb_border.b; 143 data[index2 + 3] = 255; 144 } 145 } 146 } 147 } 148 lastRGBA=RGBA; 149 } 150 151 } 152 }
这里横竖遍历所有像素,在棋子轮廓内外边界处绘制描边,算法细节可能较难以想象,建议亲自调试实验。使用这种方法绘制的描边可能比较粗糙。
4、为棋子建立不同状态的动画帧
这里以生命值变化为例:
用棋子“填充度”的降低表示棋子生命值的减少,图像生成算法如下:
1 console.log("开始生成健康状态图片"); 2 /*关于边界,因为纵向体现状态比例,所以最上边和最下边是必然存在的,用最上边和最下边之间的区域分割状态比例 3 ,然后再根据边框宽度画其他的普通边,考虑到空洞的情况,纵向和横向的普通边数量是不确定的 4 -》描边的操作应该在前一步进行!!??*/ 5 6 i_min+=size_border; 7 i_max-=size_border; 8 var i_height=i_max-i_min; 9 //接下来把它画在1800*1800的图片上(设为2048*2048可能获得更高性能和清晰度,但要求每个单元图片尺寸也必须是2的整数次幂,比如256*256),分为横5竖5最多25个状态 10 /*can_res.style.width=2048+"px"; 11 can_res.width=2048; 12 can_res.style.height=2048+"px"; 13 can_res.height=2048;*/ 14 can_res.style.width=1800+"px"; 15 can_res.width=1800; 16 can_res.style.height=1800+"px"; 17 can_res.height=1800; 18 var con_res=can_res.getContext("2d"); 19 //return; 20 //var data=data_back.data; 21 for(var h=10;h>=0;h--)//健康度状态分十一个阶段递减 22 { 23 console.log("生成"+h+"/"+10+"的图片") 24 var int_x=Math.floor((10-h)%5); 25 var int_y=Math.floor((10-h)/5); 26 if(h==10) 27 { 28 con_res.putImageData(data_back,int_x*360+60,int_y*360); 29 } 30 else 31 { 32 var i_up=Math.floor(i_max-i_height*((h+1)/10));//i偏低,取像素整体偏上 33 var i_down=Math.floor(i_max-i_height*((h)/10)+1); 34 for(var i=i_up;i<i_down;i++)//对于每一行像素 35 { 36 var j_left=0,j_right=0; 37 for(var j=0;j<240;j++) 38 { 39 var index=(i*240+j)*4; 40 if(data[index]==0&&data[index+1]==0&&data[index+2]==0) 41 { 42 if(j_left==0) 43 { 44 j_left=j; 45 data[index]=0; 46 data[index+1]=255; 47 data[index+2]=0; 48 data[index+3]=0;//将像素不透明度设为0 49 } 50 else 51 { 52 data[index]=0; 53 data[index+1]=255; 54 data[index+2]=0; 55 data[index+3]=0; 56 j_right=j; 57 } 58 } 59 } 60 /*if(j_right>0) 61 { 62 var index=(i*240+j_right)*4; 63 data[index]=0; 64 data[index+1]=0; 65 data[index+2]=0; 66 data[index+3]=255; 67 }*/ 68 69 70 } 71 //描边 72 73 con_res.putImageData(data_back,int_x*360+60,int_y*360); 74 //putImageData时完全透明的rgb通道将被丢弃??!! 75 } 76 77 78 }
5、添加“被破坏”动画帧
实现思路是在棋子上绘制不断增大的透明圆表示棋子的消逝,需要注意的是因为谷歌浏览器无法精确处理半透明计算,所以考虑到以后可能需要绘制半透明的“消逝圆”的情况,先用不透明绿色绘制消逝圆,然后统一把绿色替换为具有精确透明度的颜色。实现代码如下:
1 //接下来添加5帧栅格式的退出动画 2 for(var h=1;h<=5;h++) 3 { 4 var int_x=Math.floor((10+h)%5); 5 var int_y=Math.floor((10+h)/5); 6 con_res.putImageData(data_back,int_x*360+60,int_y*360); 7 con_res.fillStyle="rgba(0,255,0,1)";//考虑到对半透明的检查,在show图片时可以先绘制一个绿屏背景!! 8 con_res.lineWidth=0; 9 for(var i=0;i<4;i++) 10 { 11 for(var j=0;j<6;j++) 12 { 13 con_res.beginPath(); 14 con_res.arc(int_x*360+60+30+i*60,int_y*360+30+j*60,6*h,0,Math.PI*2); 15 con_res.fill();//这个方法不能正常呈现a通道 16 } 17 18 } 19 } 20 //将绿幕换成透明 21 22 var data_res=con_res.getImageData(0,0,1800,1800);// 23 var len=1800*1800*4; 24 var datar=data_res.data; 25 for(var i=0;i<len;i+=4) 26 {//这个循环内加断点会导致运算超时 27 if(datar[i]==0&&datar[i+1]==255&&datar[i+2]==0) 28 { 29 datar[i+1]=0; 30 datar[i+3]=0; 31 } 32 } 33 con_res.putImageData(data_res,0,0);
6、使用
经过前面的操作我们得到了棋子“兵”的精灵动画图片:
使用相同方法,我们可以得到其他五种棋子的精灵动画图片,或者添加更多的精灵动画帧。我们可以在Babylon.js之类WebGL引擎中使用这些精灵动画图片创建精灵动画,可以在这里找到Babylon.js的精灵动画文档:旧版文档:https://ljzc002.github.io/BABYLON101/14Sprites%20-%20Babylon.js%20Documentation.htm,新版文档:https://doc.babylonjs.com/divingDeeper/sprites/sprite_map_animations。(4.2版又有了很多新改变,也许要再次开始文档翻译工作了)