Photoshop中的自由变换工具,可以用来调整图片的几何形状,可以平移、旋转、缩放和斜切等,配合shift、ctrl和alt三键,使用起来十分灵活。一些成熟的在线PS,如Pixlr Editor,主要基于Flash平台技术实现,而目前,也出现了一些基于HTML5 Canvas实现的在线图像编辑工具,如CloudCanvas,它们都提供了自由变换工具。本人曾在自己的x项目中基于Flash平台技术实现了简单的图像自由几何变换功能,如图1所示。如今,本人对HTML5
Canvas和WebGL兴趣浓厚,于是想如何在Canvas上实现相似的功能。凭着项目得来的经验,经过一番研究,最终在无插件模式下,创建了如图2所示的Demo。
Demo在线演示,特来补上,建议使用Chrome浏览器打开(2014.10.23)。
图1
(a)
(b) (c)
(d) (e)
(f)
图2(b~f表现了初始、平移、缩放、旋转、镜像的不同状态)
这里为什么要谈到WebGL呢?因为性能,因为使用ImageData处理图像,负担全加在了JavaScript上,因为WebGL基于OpenGL ES 2.0,可以编写GLSL ES代码,可以使用GPU加速,从而减轻JavaScript的负担。如图2,实现了图像的底片效果动画,窗口右上角显示的帧率始终保持在60FPS左右。为了降低开发难度,这里额外地使用了两个类库,CreateJS和Three.js。Three.js封装了WebGL而不失灵活性,主要用来创建3D场景,而想绘制如图1所示的自由变换工具这么个有点复杂的2D图形,则不太方便、不太理想。所以,本人的做法是,图像作为纹理交给WebGL和Three.js处理,2D图形则使用HTML5
Canvas 2D和CreateJS(EaselJS)绘制,于是需要两个Canvas上下叠加在一起(可惜同一Canvas不能同时getContext("2d")又getContext("webgl"))。
下面贴出所有代码,没做什么注释。
index.html代码如下:
ImageTool
body{
margin: 0;
overflow: hidden;
position: absolute;
cursor: default;
}
index.js代码如下:
var stage=null,
ctrlframe=null,
needToUpdate=true,
renderer=null,
scene=null,
camera=null,
texture=null,
picture=null,
threshold=0,
sign=1,
stats=null;
$(function(){
var winW=window.innerWidth;
var winH=window.innerHeight;
$(window).on("resize",onResize);
//简单起见,canvas铺满整个窗口
$("canvas").attr("width",winW).attr("height",winH).css("position","absolute");
$("canvas:eq(1)").on("mousedown",onMouseDown);
stats = initStats();
renderer=new THREE.WebGLRenderer({canvas:$('canvas')[0]/*,antialias:true*/});
renderer.setClearColor(0xEEEEEE, 1.0);
renderer.setSize(winW, winH);
scene = new THREE.Scene();
camera = new THREE.OrthographicCamera(-winW/2,winW/2,winH/2,-winH/2);
camera.position.set( 0, 0, 200 );
scene.add( camera );
texture=new THREE.ImageUtils.loadTexture("assets/imgs/girl.jpg");
texture.magFilter=THREE.NearestFilter;
texture.minFilter=THREE.NearestFilter;
var geometry=new THREE.PlaneGeometry(256,256,1,1);
var material=new THREE.ShaderMaterial({
side:THREE.DoubleSide,
uniforms:{
map:{type:"t",value:texture},
threshold:{type:"f",value:1.0}
},
vertexShader:[
"varying vec2 vUV;",
"void main(){",
"vUV=uv;",
"gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );",
"}"
].join("\n"),
fragmentShader:[
"varying vec2 vUV;",
"uniform sampler2D map;",
"uniform float threshold;",
"void main(void) {",
"highp vec4 texColor = texture2D( map, vUV );",
"if(vUV.s+vUV.t
"gl_FragColor = vec4( texColor.rgb, 1.0 );",
"}else{",
"gl_FragColor=vec4(vec3(1.0,1.0,1.0)-texColor.rgb,1.0);",
"}",
"}"
].join("\n")
});
var mesh=new THREE.Mesh(geometry,material);
scene.add(mesh);
picture=mesh;
threshold=material.uniforms.threshold;
stage=new createjs.Stage($('canvas')[1]);
var bindObject=new enjolras.BindObject(picture,new createjs.Rectangle(0,0,256,256));
ctrlframe=new createjs.CtrlFrame(bindObject);
stage.addChild(ctrlframe);
onUpdate();
})
function onUpdate(){
threshold.value+=sign*0.01;
if(threshold.value>2){
sign=-1.0;
threshold.value=2;
}else if(threshold.value<0){
sign=1.0;
threshold.value=0;
}
renderer.render(scene,camera);
if(needToUpdate){//不必每一帧都更新上层画布,否则帧率会掉
stage.update();
needToUpdate=false;
}
stats.update();
requestAnimationFrame(onUpdate);
}
function onMouseDown(evt){
var state=ctrlframe.checkState(evt.clientX,evt.clientY);
if(state!="still"){
$("canvas:eq(1)").on("mouseup",onMouseUp);
$("canvas:eq(1)").on("mousemove",onMouseMove);
}
console.log("state:"+state);
}
function onMouseUp(evt){
$("canvas:eq(1)").off("mouseup",onMouseUp);
$("canvas:eq(1)").off("mousemove",onMouseMove);
ctrlframe.checkState(evt.clientX,evt.clientY);
}
function onMouseMove(evt){
ctrlframe.update(evt.clientX,evt.clientY);
needToUpdate=true;
}
function onResize(evt){
var winW=window.innerWidth;
var winH=window.innerHeight;
$("canvas").attr("width",winW).attr("height",winH);
renderer.setSize(winW, winH);
camera.left=-winW/2;
camera.right=winW/2;
camera.top=winH/2;
camera.bottom=-winH/2;
camera.updateProjectionMatrix();
ctrlframe.update();
needToUpdate=true;
stats.domElement.style.left = (winW-100)+'px';
}
function initStats() {
var stats = new Stats();
stats.setMode(0); // 0: fps, 1: ms
// Align top-right
stats.domElement.style.position = 'absolute';
stats.domElement.style.left = (window.innerWidth-100)+'px';
stats.domElement.style.top = '8px';
$("body").append(stats.domElement);
return stats;
}
enjolras.js代码如下:
createjs.CtrlFrame=function(bindObject){
createjs.Shape.call(this);
this.sx=1;
this.sy=1;
this.dx=0;
this.dy=0;
this.state="still";
this.activeIndex=-1;
this.circles=null;
this.bindedObject=null;
if(bindObject){
this.bind(bindObject);
}
}
createjs.CtrlFrame.prototype=Object.create(createjs.Shape.prototype);
createjs.CtrlFrame.prototype.constructor=createjs.CtrlFrame;
createjs.CtrlFrame.prototype.bind=function(bindObject){
this.bindedObject=bindObject;
var bounds=bindObject.getBounds();
this.setBounds(bounds.x,bounds.y,bounds.width,bounds.height);
this.x=bindObject.x;
this.y=bindObject.y;
this.sx=bindObject.scaleX;
this.sy=bindObject.scaleY;
this.rotation=bindObject.rotation;
this.updateCircles();
this.drawCircles();
};
createjs.CtrlFrame.prototype.updateBindedOject=function(){
var bindedObject=this.bindedObject;
bindedObject.x=this.x;
bindedObject.y=this.y;
bindedObject.scaleX=this.sx;
bindedObject.scaleY=this.sy;
bindedObject.rotation=this.rotation;
};
createjs.CtrlFrame.prototype.drawCircles=function(){
var i=0,circle=null;
var circles=this.circles;
var colors=["red","orange","yellow","green","cyan","blue","purple","pink","lime"];
var graphics=this.graphics;
graphics.clear().setStrokeStyle(2).beginStroke("#0af");//beginStroke("#444");
/*graphics.moveTo(circles[0].x,circles[0].y);
graphics.lineTo(circles[2].x,circles[2].y);
graphics.lineTo(circles[8].x,circles[8].y);
graphics.lineTo(circles[6].x,circles[6].y);
graphics.lineTo(circles[0].x,circles[0].y);*/
graphics.drawRect(circles[0].x,circles[0].y,circles[8].x<<1,circles[8].y<<1);
for(i=0;i
/*if(i==4){
continue;
}*/
circle=circles[i];
graphics.beginFill(colors[i]).drawCircle(circle.x,circle.y,10).endFill();
}
graphics.endStroke();
};
createjs.CtrlFrame.prototype.updateCircles=function(){
var row,col,halfW,halfH;
var circles=[];
var bounds=this.getBounds();
halfW=(bounds.width>>1)*this.sx;
halfH=(bounds.height>>1)*this.sy;
for(var i=0;i<9;i++){
row=Math.floor(i/3);
col=Math.floor(i%3);
circles[i]=new createjs.Point((col-1)*halfW,(row-1)*halfH);
}
if(this.circles){
this.circles.splice(0);
}
this.circles=circles;
};
createjs.CtrlFrame.prototype.decideActiveIndex=function(){
var circles=this.circles;
var bounds=this.getBounds();
var theta=this.rotation*Math.PI/180;
var dx=this.dx*Math.cos(-theta)-this.dy*Math.sin(-theta);
var dy=this.dx*Math.sin(-theta)+this.dy*Math.cos(-theta);
if(Math.abs(dx)>(bounds.width>>1)*Math.abs(this.sx)+10
||Math.abs(dy)>(bounds.height>>1)*Math.abs(this.sy)+10){
this.activeIndex=-1;
}
else{
this.activeIndex=4;
for(var i=0;i<9;i++){
if(i==4){
continue;
}
if(Math.abs(dx-circles[i].x)<10&&Math.abs(dy-circles[i].y)<10){
this.activeIndex=i;
break;
}
}
}
};
createjs.CtrlFrame.prototype.checkState=function(x,y){
this.dx=x-this.x;
this.dy=y-this.y;
this.decideActiveIndex();
switch(this.activeIndex){
case 4://cyan
this.state="translate";
break;
case 0://red
case 2://yellow
case 6://purple
case 8://lime
case 5://blue 横向
case 7://pink 纵向
this.state="scale";
break;
case 1://orange
case 3://green
this.state="rotate";
break;
default:
this.state="still";
}
return this.state;
};
createjs.CtrlFrame.prototype.update=function(x,y){
if(x==undefined || this.state=="still"){
this.updateBindedOject();
return;
}
if(this.state=="translate"){
this.x=x-this.dx;
this.y=y-this.dy;
}else{
this.dx=x-this.x;
this.dy=y-this.y;
if(this.state=="scale"){
this.scale();
}else{
this.rotate();
}
}
this.updateBindedOject();
};
createjs.CtrlFrame.prototype.scale=function(){
var row=Math.floor(this.activeIndex/3);
var col=Math.floor(this.activeIndex%3);
var theta=this.rotation*Math.PI/180;
var cos=Math.cos(-theta);
var sin=Math.sin(-theta);
var bounds=this.getBounds();
var dx=this.dx*cos-this.dy*sin;
var dy=this.dx*sin+this.dy*cos;
if(col!=1){
this.sx=dx*(col-1)/(bounds.width>>1);
}
if(row!=1){
this.sy=dy*(row-1)/(bounds.height>>1);
}
this.updateCircles();
this.drawCircles();
};
createjs.CtrlFrame.prototype.rotate=function(){
var delta=0;
if(this.activeIndex==1){
delta=Math.PI*0.5*(this.sy<0?-1:1);
}else{
delta=Math.PI*(this.sx<0?0:1);
}
var theta=Math.atan2(this.dy,this.dx)+delta;
this.rotation=theta*180/Math.PI;
};
var enjolras = enjolras || { };
//建立CreateJS与ThreeJS之间的绑定
enjolras.BindObject=function(picture,bounds){
this._bounds=bounds;
this._position=picture.position;
this._rotation=picture.rotation;
this._scale=picture.scale;
console.log("Enjolras: Create a binding between CreateJS and ThreeJS");
};
//WebGL和Three.js采用右手坐标系,初始状态下原点落在Canvas中心
enjolras.BindObject.prototype={
getBounds:function(){
return this._bounds;
},
get x(){
return this._position.x+(window.innerWidth>>1);
},
set x(value){
this._position.x=(value-(window.innerWidth>>1));
},
get y(){
return this._position.y+(window.innerHeight>>1);
},
set y(value){
this._position.y=-(value-(window.innerHeight>>1));
},
get rotation(){
return -this._rotation.z*180/Math.PI;
},
set rotation(value){
this._rotation.z=-value*Math.PI/180;
},
get scaleX(){
return this._scale.x;
},
set scaleX(value){
this._scale.x=value;
},
get scaleY(){
return this._scale.y;
},
set scaleY(value){
this._scale.y=value;
}
};
这里,平移、旋转、缩放都围绕中心点(浅蓝色的点)来进行,没有实现斜切功能,多处为硬编码,可以改进的地方还很多。