1

导言

3D引擎那么火,你让2D怎么办? 闲来无事,用Canvas实现3D效果的业务关系,是否也是一种乐趣?

先睹为快

Paste_Image.png

实验天地

WebGL能绘制3D效果,Canvas的2D绘图就不能了吗? 其实不然,也能绘制,只是消耗的都是内存,绘制效率自然收到影响;但若场景不大,3D效果不太真,也不妨试试;

Canvas绘制3D Cube

<html>
<head>
 <meta charset="gbk" />
 <title>3D cube HTML5 canvas realization</title>
 <script type="text/javascript">
  function color(r, g, b, a)
  {
    this.r = r;
    this.g = g;
    this.b = b;
    this.a = a;
  }
  function point2D(x, y)
  {
    this.x = x;
    this.y = y;
  }
  point2D.prototype.move = function(p2D)
  {
    this.x += p2D.x;
    this.y += p2D.y;
  }
  function point3D(x, y, z)
  {
    this.x = x;
    this.y = y;
    this.z = z;
  }
  point3D.prototype.move = function(p3D)
  {
    this.x += p3D.x;
    this.y += p3D.y;
    this.z += p3D.z;
  }
  point3D.prototype.swap = function(p3D)
  {
    this.x = p3D.x;
    this.y = p3D.y;
    this.z = p3D.z;
  }
  point3D.prototype.rotate = function(axis, angleGr)
  {
    angleRad = angleGr * Math.PI / 180;
    switch (axis)
    {
      case "x":
      {
       var tempPoint = new point3D(
         this.x,
         this.y * Math.cos(angleRad) - this.z * Math.sin(angleRad),
         this.y * Math.sin(angleRad) + this.z * Math.cos(angleRad)
         );
       this.swap(tempPoint);
       break;
     } 
     case "y":
     {
       var tempPoint = new point3D(
         this.x * Math.cos(angleRad) + this.z * Math.sin(angleRad),
         this.y,
         -this.x * Math.sin(angleRad) + this.z * Math.cos(angleRad)
         );
       this.swap(tempPoint);
       break;
     } 
     case "z":
     {
       var tempPoint = new point3D(
         this.x * Math.cos(angleRad) - this.y * Math.sin(angleRad),
         this.x * Math.sin(angleRad) + this.y * Math.cos(angleRad),
         this.z
         );
       this.swap(tempPoint);
       break;
     } 
   }
 }
 function normal3D(p3D, length)
 {
  this.point = p3D;
  this.length = length;
}
function poly()
{
  var points = [];
  for(var i = 0; i < arguments.length; i++)
    points.push(arguments[i]);
  this.points = points;
    // Calculating normal
    var v1 = new point3D(points[2].x - points[1].x, points[2].y - points[1].y, points[2].z - points[1].z);
    var v2 = new point3D(points[0].x - points[1].x, points[0].y - points[1].y, points[0].z - points[1].z);
    var normalP3D = new point3D(v1.y*v2.z-v2.y*v1.z, v1.z*v2.x-v2.z*v1.x, v1.x*v2.y-v2.x*v1.y);
    var normalLen = Math.sqrt(normalP3D.x*normalP3D.x + normalP3D.y*normalP3D.y + normalP3D.z*normalP3D.z);
    this.normal = new normal3D(normalP3D, normalLen);
  }
  poly.prototype.move = function(p3D)
  {
    for(var i = 0; i < this.points.length; i++)
    {
      var point = this.points[i];
      point.move(p3D);
    }
  }
  poly.prototype.rotate = function(axis, angle)
  {
    for(var i = 0; i < this.points.length; i++)
    {
      var point = this.points[i];
      point.rotate(axis, angle);
    }
    
    this.normal.point.rotate(axis, angle);
  }
  poly.prototype.put = function(center, fillColor, edgeColor)
  {
    // Calulate visibility
    var normalAngleRad = Math.acos(this.normal.point.z/this.normal.length);
    if(normalAngleRad / Math.PI * 180 >= 90)
      return;
    var lightIntensity = 1 - 2 * (normalAngleRad / Math.PI);
    ctx.fillStyle = 'rgba('+fillColor.r+','+fillColor.g+','+fillColor.b+','+
    (fillColor.a*lightIntensity)+')';
    ctx.beginPath();
    for(var i = 0; i < this.points.length; i++)
    {
      var point = this.points[i];
      if(i)
       ctx.lineTo(center.x + parseInt(point.x), center.y - parseInt(point.y));
     else
       ctx.moveTo(center.x + parseInt(point.x), center.y - parseInt(point.y));
   }
   ctx.fill();
   ctx.lineWidth = 1;
   ctx.strokeStyle = 'rgba('+edgeColor.r+','+edgeColor.g+','+edgeColor.b+','+
   (edgeColor.a*lightIntensity)+')';
   ctx.beginPath();
   var point = this.points[this.points.length-1];
   ctx.moveTo(center.x + parseInt(point.x), center.y - parseInt(point.y));
   for(var i = 0; i < this.points.length; i++)
   {
    var point = this.points[i];
    ctx.lineTo(center.x + parseInt(point.x), center.y - parseInt(point.y));
  }
  ctx.stroke();
}
function Cube(size, fillColor, edgeColor)
{
  var p000 = new point3D(0,0,0);
  var p0S0 = new point3D(0,size,0);
  var pSS0 = new point3D(size,size,0);
  var pS00 = new point3D(size,0,0);
  var p00S = new point3D(0,0,size);
  var p0SS = new point3D(0,size,size);
  var pSSS = new point3D(size,size,size);
  var pS0S = new point3D(size,0,size);
  var polys = [];
  polys.push(new poly(p000,p0S0,pSS0,pS00));
  polys.push(new poly(pS00,pSS0,pSSS,pS0S));
  polys.push(new poly(pS0S,pSSS,p0SS,p00S));
  polys.push(new poly(p00S,p0SS,p0S0,p000));
  polys.push(new poly(p0S0,p0SS,pSSS,pSS0));
  polys.push(new poly(p00S,p000,pS00,pS0S));
  this.polys = polys;
  var points = [];
  points.push(p000);
  points.push(p0S0);
  points.push(pSS0);
  points.push(pS00);
  points.push(p00S);
  points.push(p0SS);
  points.push(pSSS);
  points.push(pS0S);
  for(var i = 0; i < polys.length; i++)
  {
    points.push(polys[i].normal.point);
  }
  this.points = points;
  this.fillColor = fillColor;
  this.edgeColor = edgeColor;
}
function move(o3D, p3D)
{    
  for(var i = 0; i < o3D.points.length - o3D.polys.length; i++)
  {
    var point = o3D.points[i];
    point.move(p3D);
  }
}
function put(o3D, center)
{
  for(var i = 0; i < o3D.polys.length; i++)
  {
    var poly = o3D.polys[i];
    poly.put(center, o3D.fillColor, o3D.edgeColor);
  }
}
function rotate(o3D, axis, angle)
{
  for(var i = 0; i < o3D.points.length; i++)
  {
    var point = o3D.points[i];
    point.rotate(axis, angle);
  }
}
function init(){
  canvas = document.getElementById('3Dcube');
  if (canvas.getContext){
    ctx = canvas.getContext('2d');
    ctx.fillStyle = 'rgba(0, 0, 0, 1)';
      ctx.fillRect(0, 0, canvas.width, canvas.height); // clear canvas
      cube = new Cube(100, new color(50,50,200,1), new color(60,60,210,1));
      move(cube, new point3D(-50,-50,-50));
      rotate(cube, 'x', 45);
      rotate(cube, 'y', 45);
      rotate(cube, 'z', 45);
      
      centerScreen = new point2D(canvas.width / 2, canvas.height / 2);
      put(cube, centerScreen);
      timer = setInterval(nextFrame, 1000 / 60);
    }
  }
  function nextFrame()
  {
    ctx.fillStyle = 'rgba(0, 0, 0, 1)';  
    ctx.fillRect(0, 0, canvas.width, canvas.height);  // clear canvas
    rotate(cube, 'x', 0.4);
    rotate(cube, 'y', 0.6);
    rotate(cube, 'z', 0.3);
    ctx.fillStyle = 'rgba(50, 50, 200, 1)';
    ctx.strokeStyle = 'rgba(60, 60, 210, 1)';
    put(cube, centerScreen);
  }
</script>
<style type="text/css">
 canvas { border: 0px solid black; }
</style>
</head>
<body onload="init();">
 <h1>3D cube HTML5 canvas realization on 2D contex</h1>
 <p>Features:
   <ul>
    <li>3D operations: rotating, moving object center</li>
    <li>Direct illumination</li>
    <li>Highlighting edges</li>
    <li>Optimizations: 
      <ul>
        <li>Skip outputting of invisible polygons</li>
        <li>Skip processing of duplicated points</li>
      </ul>
    </li>
  </ul>
</p>
<canvas id="3Dcube" width="400" height="225"></canvas>
</body>
</html>

效果

Paste_Image.png

Paste_Image.png

封装一个Cube模块

CNode = function(id) {
  CNode.superClass.constructor.call(this, id);
}

twaver.Util.ext("CNode", twaver.Node, {
  _split:1/3,
  _cubeAngle:Math.PI/6,
  getVectorUIClass: function (){
    return CNodeUI;
  },
  setSplit:function(split){
    this._split = split;
  },
  setCubeAngle:function(angle){
    this._cubeAngle = angle;
  }
});

CNodeUI = function(network, element) {
  CNodeUI.superClass.constructor.call(this, network, element);
}

twaver.Util.ext('CNodeUI', twaver.vector.NodeUI, {
  drawVectorBody : function(ctx) {
      // CNodeUI.superClass.drawVectorBody.call(this, ctx);
      var node = this._element;
      var rect = this.getZoomBodyRect();
      // rect.x = rect.x + rect.width /4;
      // rect.y = rect.y + rect.height /4;
      // rect.width /= 2;
      // rect.height /= 2;
      var angleSin = Math.sin(node._cubeAngle);
      var angleCos = Math.cos(node._cubeAngle);
      var angleTan = Math.tan(node._cubeAngle);
      var split = node._split;
      var dash = false;
      var fill = false;
      var fillColor = this.getStyle('vector.fill.color');
      var close = false;

      var cubeDepth = node._width * split/angleCos;
      var cubeWidth = node._width * (1 - split) / angleCos;
      // var cubeHeight = rect.height/3;
      var cubeHeight = rect.height - cubeWidth * angleSin - cubeDepth * angleSin;
      var angle = node.getClient('angle');

      var center = {x:rect.x + rect.width/2,y:rect.y + rect.height/2};
      var p1 = {},p2 = {}, p3 = {}, p4 = {}, p5 = {},p6 = {}, p7 = {}, p8 = {};
      p1.x = rect.x + rect.width * split;
      p1.y = rect.y + rect.height;

      p2.x = rect.x;
      p2.y = rect.y + rect.height - cubeDepth * angleSin;

      p3.x = p2.x;
      p3.y = p2.y - cubeHeight;

      p4.x = p1.x;
      p4.y = p1.y - cubeHeight ;

      p6.x = rect.x + rect.width;
      p6.y = rect.y + rect.height - cubeWidth * angleSin;

      p5.x = p6.x;
      p5.y = p6.y - cubeHeight;

      p7.x = rect.x + rect.width * (1 - split);
      p7.y = rect.y;

      p8.x = p7.x;
      p8.y = p7.y + cubeHeight;

      p1 = this.rotatePoint(center,p1,angle * Math.PI / 180);
      p2 = this.rotatePoint(center,p2,angle * Math.PI / 180);
      p3 = this.rotatePoint(center,p3,angle * Math.PI / 180);
      p4 = this.rotatePoint(center,p4,angle * Math.PI / 180);
      p5 = this.rotatePoint(center,p5,angle * Math.PI / 180);
      p6 = this.rotatePoint(center,p6,angle * Math.PI / 180);
      p7 = this.rotatePoint(center,p7,angle * Math.PI / 180);
      p8 = this.rotatePoint(center,p8,angle * Math.PI / 180);


      close = false;
      dash = true;
      fill = false;
      this.drawPoints(ctx,[p2,p8],close,dash,fill);
      this.drawPoints(ctx,[p7,p8],close,dash,fill);
      this.drawPoints(ctx,[p6,p8],close,dash,fill);

      dash = false;
      close = true;
      fill = true;
      this.drawPoints(ctx,[p1,p2,p3,p4],close,dash,fill,fillColor);
      this.drawPoints(ctx,[p1,p4,p5,p6],close,dash,fill);
      this.drawPoints(ctx,[p3,p4,p5,p7],close,dash,fill);
    },
    drawPoints:function(ctx,points,close,dash,fill,fillColor){
      if(!points || points.length == 0){
        return;
      }
      ctx.beginPath();
      ctx.strokeStyle = "black";
      ctx.lineWidth = 0.5;
      if(fill && fillColor) {
        ctx.fillStyle = fillColor.colorRgb(0.6);
      }
      if(dash){
        ctx.setLineDash([8,8]);
        ctx.strokeStyle = 'rgba(0,0,0,0.5)';
      }else{
        ctx.setLineDash([1,0]);
      }
      ctx.moveTo(points[0].x,points[0].y);

      for(var i = 1;i < points.length; i++){
        var p = points[i];
        ctx.lineTo(p.x,p.y);
      }

      if(close){
        ctx.lineTo(points[0].x,points[0].y);
      }

      ctx.closePath();
      ctx.stroke();
      if(fill){
        ctx.fill();
      }
    },
    rotatePoint:function(center,p,angle) {
      var x = (p.x - center.x) * Math.cos(angle) - (p.y - center.y) * Math.sin(angle) + center.x;
      var y = (p.x - center.x) * Math.sin(angle) + (p.y - center.y) * Math.cos(angle) + center.y;
      return {x:x, y:y};
    },
  });

就是把小学初中所学的几何知识用上就可以了;

Paste_Image.png

换个色

再封装一个倾斜平面

var CGroup = function(id){
      CGroup.superClass.constructor.apply(this, arguments);
      this.enlarged = false;
    };

    twaver.Util.ext(CGroup, twaver.Group, {
      _tiltAngle:45,
      getTiltAngleX : function() {
        return this._tiltAngle;
      },
      setTiltAngleX : function(angle) {
        var oldValue = this._tiltAngle;
        this._tiltAngle = angle % 360;
        this.firePropertyChange("tiltAngleX", oldValue, this._tiltAngle);
      },
      getVectorUIClass:function(){
        return CGroupUI;
      },
      isEnlarged:function() {
        return this.enlarged;
      },
      setEnlarged:function(value){
        this.enlarged = value;
        var fillColor;
        if(value === false){
          this.setClient("group.angle",this._tiltAngle);
          this.setClient("group.shape","parallelogram");
          this.setClient("group.deep",10);
          this.setStyle("select.style","none");
          // this.setStyle("group.gradient","linear.northeast");
          this.setStyle("group.gradient","radial.center");
          this.setStyle("group.deep",0);
          this.setStyle("label.position","right.left");
          this.setStyle("label.xoffset",-10);
          this.setStyle("label.yoffset",-30);
          this.setStyle("label.font","italic bold 12px/30px arial,sans-serif");
          fillColor = this.changeHalfOpacity(this.getStyle("group.fill.color"));
          this.setStyle("group.fill.color",fillColor);
          this.setAngle(-20);
        }else{
         this.setAngle(0);
         this.setClient("group.angle",1);
         this.setClient("group.shape","parallelogram");
         this.setClient("group.deep",0);

         this.setStyle("select.style","none");
         this.setStyle("group.gradient","linear.northeast");
         this.setStyle("group.deep",0);
         this.setStyle("label.position","right.right");
         this.setStyle("label.xoffset",0);
         this.setStyle("label.yoffset",0);
         this.setStyle("label.font","italic bold 12px/30px arial,sans-serif");

         fillColor = this.changeOpacity(this.getStyle("group.fill.color"));
         this.setStyle("group.fill.color",fillColor);
       }
     },

     increaseOpacity:function(rgba){
      if(typeof rgba === "string" && rgba.indexOf("rgba(") !== -1 && rgba.indexOf(")") !== -1){
        var rgbaSub = rgba.substring(5, rgba.length-1);
        var rgbaNums = rgbaSub.split(",");
        var returnColor ="rgba(";
        var i;
        for(i=0;i<rgbaNums.length;i++){
          if(i !== rgbaNums.length-1){
            returnColor = returnColor +rgbaNums[i]+",";
          }else{
            var opacity = parseFloat(rgbaNums[i])+0.25;
            returnColor = returnColor +opacity+")";
          }
        }
        return returnColor;
      }else{
        return rgba;
      }
    },
    changeOpacity:function(rgba){
      if(typeof rgba === "string" && rgba.indexOf("rgba(") !== -1 && rgba.indexOf(")") !== -1){
        var rgbaSub = rgba.substring(5, rgba.length-1);
        var rgbaNums = rgbaSub.split(",");
        var returnColor ="rgba(";
        var i;
        for(i=0;i<rgbaNums.length;i++){
          if(i !== rgbaNums.length-1){
            returnColor = returnColor +rgbaNums[i]+",";
          }else{
            var opacity = 1;
            returnColor = returnColor +opacity+")";
          }
        }
        return returnColor;
      }else{
        return rgba;
      }
    },
    changeHalfOpacity:function(rgba){
      if(typeof rgba === "string" && rgba.indexOf("rgba(") !== -1 && rgba.indexOf(")") !== -1){
        var rgbaSub = rgba.substring(5, rgba.length-1);
        var rgbaNums = rgbaSub.split(",");
        var returnColor ="rgba(";
        var i;
        for(i=0;i<rgbaNums.length;i++){
          if(i !== rgbaNums.length-1){
            returnColor = returnColor +rgbaNums[i]+",";
          }else{
            var opacity = 0.5;
            returnColor = returnColor +opacity+")";
          }
        }
        return returnColor;
      }else{
        return rgba;
      }
    },
  });

    var CGroupUI = function(network,element){
      CGroupUI.superClass.constructor.apply(this, arguments);
    };

    twaver.Util.ext(CGroupUI, twaver.vector.GroupUI, {
      createBodyRect: function () {
        this._shapeRect = null;
        var group = this._element;
        var network = this._network;
        var rect = null;
        if (group.isExpanded()) {
         group.getChildren().forEach(function (child) {
          var ui = network.getElementUI(child);
          ui && ui.validate();
        });
         var rects = this.getChildrenRects();
         if (!rects.isEmpty()) {
          var shape = group.getStyle('group.shape');
          var func = _twaver.group[shape];
          if (!func) {
            throw "Can not resolve group shape '" + shape + "'";
          }
          this._shapeRect = func(rects);
          var orgRect = this._shapeRect;
          if (group._angle !== 0) {
            var matrix = _twaver.math.createMatrix(group._angle * Math.PI / 180, orgRect.x + orgRect.width / 2, orgRect.y + orgRect.height / 2);
            var points = [{
              x : orgRect.x,
              y : orgRect.y
            }, {
              x : orgRect.x + orgRect.width,
              y : orgRect.y
            }, {
              x : orgRect.x + orgRect.width,
              y : orgRect.y + orgRect.height
            }, {
              x : orgRect.x,
              y : orgRect.y + orgRect.height
            }];
            for (var i = 0, n = points.length; i < n; i++) {
              points[i] = matrix.transform(points[i]);
            }
            rect = _twaver.math.getRect(points);
          }else{
            rect = this._shapeRect;
          }
        }
      }
      if (rect) {
        _twaver.math.addPadding(rect, group, 'group.padding', 1);
        return rect;
      } else {
        return twaver.vector.GroupUI.superClass.createBodyRect.call(this);
      }
    },
    validateBodyBounds: function () {
      var $math=_twaver.math;
      var node = this._element;
      this.getBodyRect();
      var shape = node.getClient("group.shape");
      if (shape === "parallelogram" && this._shapeRect) {
        var rect = this.getPathRect("group", false);
        var deep = this.getStyle('group.deep');
        var groupDeep = node.getClient('group.deep');
        var parallelogramAngle = node.getClient("group.angle") * Math.PI / 180;
        var xOffset = this._shapeRect.height*Math.tan(parallelogramAngle);
        this._shapeRect.width = this._shapeRect.width + xOffset*3/2;
        this._shapeRect.height = this._shapeRect.height + groupDeep;
        this._shapeRect.x = this._shapeRect.x-xOffset*3/4;

        var rectXOffset = rect.height*Math.tan(parallelogramAngle);
        rect.width = rect.width + rectXOffset*3/2;
        rect.x = rect.x - rectXOffset*3/4;
        $math.grow(rect,deep+1,deep+1);
        this.addBodyBounds(rect);

        var bound=_twaver.cloneRect(rect);
        bound.width+=10;
        bound.height+=10;
        this.addBodyBounds(bound);
      } else {
        twaver.vector.GroupUI.superClass.validateBodyBounds.call(this);
      }
    },

    drawPath : function(ctx, prefix, padding, pattern, points, segments, close) {
      var $g=_twaver.g;
      var zoomManager = this._network.zoomManager;
      var node = this._element;
      var rect = null;
      var shape = node.getClient("group.shape");
      if(shape === "parallelogram"){
        if (prefix == 'group') {
          rect = this._shapeRect;
        } else {
          rect = this.getZoomBodyRect();
        };
        if (padding) {
          $math.addPadding(rect, node, prefix + '.padding', 1);
        }
        var lineWidth = node.getStyle(prefix + '.outline.width');
        this.setGlow(this, ctx);
        this.setShadow(this, ctx);
        if (node.getAngle() != 0) {
          if (!( node instanceof twaver.Group)) {
            rect = node.getOriginalRect();
            rect = zoomManager._getElementZoomRect(this, rect);
          }
          ctx.save();
          twaver.Util.rotateCanvas(ctx, rect, node.getAngle());
        }

        var fill = node.getStyle(prefix + '.fill');
        var fillColor;
        if (fill) {
          if (this._innerColor && !$element.hasDefault(this._element)) {
            fillColor = this._innerColor;
          } else {
            fillColor = node.getStyle(prefix + '.fill.color');
          }
          var gradient = node.getStyle(prefix + '.gradient');
          if (gradient) {
            $g.fill(ctx, fillColor, gradient, node.getStyle(prefix + '.gradient.color'), rect);
          } else {
            ctx.fillStyle = fillColor;
          }
        }

        ctx.lineJoin = "round";
        ctx.lineWidth = 10;
        ctx.strokeStyle = "#435474".colorRgb(0.8);

      //draw round rect body.
      var parallelogramAngle = node.getClient("group.angle") * Math.PI / 180;
      var xOffset = rect.height*Math.tan(parallelogramAngle);
      var groupDeep = node.getClient('group.deep');
      if(parallelogramAngle){
        ctx.save();
        ctx.beginPath();
        ctx.moveTo(rect.x, rect.y);
        ctx.lineTo(rect.x+rect.width-xOffset, rect.y);
        ctx.lineTo(rect.x+rect.width, rect.y+rect.height-groupDeep);
        ctx.lineTo(rect.x+xOffset,rect.y+rect.height-groupDeep);
        ctx.closePath();
        ctx.fill();
        ctx.stroke();
        ctx.restore();

        if(fillColor.indexOf("rgba(") !== -1){
          var changedColor = this._element.increaseOpacity(fillColor);
          ctx.fillStyle=changedColor;
          ctx.save();
          ctx.beginPath();
          ctx.moveTo(rect.x+xOffset, rect.y+rect.height-groupDeep);
          ctx.lineTo(rect.x+rect.width, rect.y+rect.height-groupDeep);
          ctx.lineTo(rect.x+rect.width, rect.y+rect.height);
          ctx.lineTo(rect.x+xOffset, rect.y+rect.height);
          ctx.closePath();
          ctx.fill();
          ctx.stroke();
          ctx.restore();

          changedColor = this._element.increaseOpacity(changedColor);
          ctx.fillStyle=changedColor;
          ctx.save();
          ctx.beginPath();
          ctx.moveTo(rect.x+xOffset, rect.y+rect.height-groupDeep);
          ctx.lineTo(rect.x+xOffset, rect.y+rect.height);
          ctx.lineTo(rect.x, rect.y + groupDeep);
          ctx.lineTo(rect.x, rect.y);
          ctx.closePath();
          ctx.fill();
          ctx.stroke();
          ctx.restore();
        }
      }     
    }else{
   twaver.vector.GroupUI.superClass.drawPath.apply(this,arguments);
    }
    if (node.getAngle() != 0) {
      ctx.restore();
    }
  },
});

平面

参考文献

[1].canvas实现简单3D旋转效果


FuJie
50 声望24 粉丝

Hello EcmaScript!