Canvas实现月亮的变化,模拟正确的月相变化🌙操碎了心😫

1,796 阅读5分钟

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

实现一个月亮

月亮的实现通过如下步骤:

  1. 填充画布黑色,作为黑夜背景
  2. 画一个圆,填充颜色,表示月亮
<canvas id="c1" width="600" height="500"></canvas>

<script>
  let canvas=document.getElementById("c1");
  let ctx=canvas.getContext("2d");
  
  let width=canvas.width,height=canvas.height;
  // 圆点
  let origin={
      x:width/2,
      y:height/2
  }

  // 填充整个画布
  ctx.fillStyle="#000";
  ctx.fillRect(0,0,canvas.width,canvas.height);
  // 月亮
  let moon_radius=80;
  let moon_origin={
      x:origin.x-60,
      y:origin.y
  }
  
  ctx.beginPath();
  ctx.arc(moon_origin.x,moon_origin.y,moon_radius,0,2*Math.PI);
  ctx.fillStyle="yellow";
  ctx.fill();
  ctx.stroke();
</script>

添加一个球形对月亮的遮挡

由于公转等因素,地球和月亮相对的远近距离,会对月亮造成不同的遮挡效果。

如下,画一个比月亮稍大的阴影球,实现对月亮的遮挡!

let earth_radius=100;
ctx.beginPath();
ctx.arc(origin.x, origin.y, earth_radius, 0, 2 * Math.PI);
ctx.fillStyle="#000";
ctx.fill();
ctx.stroke();

动画化月亮的遮挡效果

一个在canvas上绘制的球类

class Ball {
   constructor(props) {
       // 小球坐标 默认0,0 半径 20
       this.x = 0;
       this.y = 0;
       this.r = 20;
       // 横向 纵向 缩放倍数
       this.scaleX = 1;
       this.scaleY = 1;
       // 绘制开始结束弧度
       this.startArc=0;
       this.endArc=2 * Math.PI;

       //描边和填充颜色
       this.strokeStyle = "rgba(0,0,0,0)"; //透明黑色,不描边
       this.fillStyle = "red"
       this.alpha = 1;

       Object.assign(this, props); // 初始化props配置参数
       return this;
   }
   render(ctx) {
       let {
           x,
           y,
           r,
           scaleX,
           scaleY,
           fillStyle,
           strokeStyle,
           alpha,
           startArc,
           endArc
       } = this;
       ctx.save();
       // 坐标原点移动到绘制坐标
       ctx.translate(x, y);
       ctx.scale(scaleX, scaleY);
       ctx.strokeStyle = strokeStyle,
           ctx.fillStyle = fillStyle;
       ctx.globalAlpha = alpha;
       ctx.beginPath();
       ctx.arc(0, 0, r, startArc, endArc);
       ctx.fill();
       ctx.stroke();

       ctx.restore();
       return this;
   }
}

改造月亮遮挡代码为Ball Class实现

let width=canvas.width,height=canvas.height;
ctx.fillStyle="#000";
ctx.fillRect(0,0,canvas.width,canvas.height);

// 月亮
let moon=new Ball({
   r:80,
   x:width/2-60,
   y:height/2,
   fillStyle:"yellow"
});

moon.render(ctx);

let earth=new Ball({
   r:100,
   x:width/2,
   y:height/2,
   fillStyle:"#000"
});

earth.render(ctx);

遮挡动画

function initNight(ctx){
   ctx.save();
   ctx.fillStyle="#000";
   ctx.fillRect(0,0,width,height);
   ctx.restore();
}
initNight(ctx);

// 月亮
let moon=new Ball({
   r:80,
   x:width/2-50,
   y:height/2,
   fillStyle:"yellow"
});
let earth=new Ball({
   r:90,
   x:moon.x+moon.r+90,
   y:height/2,
   fillStyle:"#000"
});

moon.render(ctx);
earth.render(ctx);

// 开始位置
let startX=earth.x;
let endX=earth.x-earth.r*2-moon.r*2;
let delta=endX-startX;
let speed=0.1,nowStep = 0, step = 100;

function maskMove(){
   nowStep+=speed;
   if(nowStep<step){ 
       let currX=startX+ delta*(nowStep/step);
       earth.x=currX;

       ctx.clearRect(0, 0, width, height);
       initNight(ctx);
       moon.render(ctx);
       earth.render(ctx);
   }
   else{
       nowStep=0;
   }
}

(function move() {
   window.requestAnimationFrame(move);
   maskMove();
})();

如下,是月亮从新月、峨眉月,到上玄月、满月、下玄月等的一周运动过程。

月相变化

月相

月相变化的顺序是:新月——娥眉月——上弦月——盈凸(凸月)—— 满月 ——亏凸(凸月)——下弦月——残月(下峨嵋月)——新月,月相变化是周期性的,周期大约是月。

月相的更替周期是29.53天,称为一个朔望月。

新月也叫朔,或朔日。

下面是一个月相的变化周期图:

月相和角度

不同月相的角度:

1、新月(农历初一日,即朔日):0度;

2、上峨嵋月(一般为农历的初二夜左右-------初七日左右):0度----90度;

3、上弦月(农历初八左右):90度;

4、凸月(农历初九左右-----农历十四左右):90度----180度;

5、满月(望月,农历十五日夜或十六日左右):180度;

6、凸月(农历十六左右-----农历二十三左右):180度----270度;

7、下弦月(农历二十三左右):270度;

8、下峨嵋月(农历二十四左右----月末):270度-----360度;另外,农历月最后一天称为晦日月亮,即不见。

修改真实的月相变化效果

从月相的变化来看,整个月亮的变化并不是一个球体移动过去的效果。更像是半圆开合。

使用arcTo实现近似的效果

// 起始点
let phaseMoonStartX=width/2;
let phaseMoonStartY=height/2;
let phaseMoon_r=80;

ctx.save();
// 坐标原点移动到绘制坐标
ctx.translate(phaseMoonStartX, phaseMoonStartY);
ctx.beginPath();
ctx.moveTo(0,0);
ctx.arcTo(200, 80, 0, 160, phaseMoon_r);
ctx.lineTo(0, 160);

ctx.fillStyle="yellow";

ctx.stroke();
ctx.fill();

ctx.restore();

ctx.save();
// 坐标原点移动到绘制坐标
ctx.translate(phaseMoonStartX, phaseMoonStartY);
ctx.beginPath();
ctx.moveTo(0,0);
// 控制点1 x坐标为100
// ctx.arcTo(100, 80, 0, 160, phaseMoon_r);
// 控制点1 x坐标为20
ctx.arcTo(20, 80, 0, 160, phaseMoon_r*6);
ctx.lineTo(0, 160);

ctx.fillStyle="#000";
ctx.stroke();
ctx.fill();

ctx.restore();

但是可以看到,使用arcTo函数,生成的线条非常不流畅,有些生硬,重点是arcTo的半径也不好控制。

两个弧线相交实现的月亮效果

如下,如果一个大圆弧和一小小圆弧,相交生成的月亮效果图,各自的半径关系可以很好的计算出来。

但是随着dist的增加,大圆的半径也是呈指数级别增长,直到无穷大。

关于正负无穷的变化和计算,是一个问题...

而第二个大圆,或圆弧,改为arcTo实现,同样面对着无穷大的问题,或者条线变化生硬的问题。

那么,除此之外就是使用贝赛尔曲线(bezierCurveTo())实现小圆竖直中心线相交点,作为贝赛尔曲线的起点和终点,进行绘制,不知是否线条会生硬?

最后,就是借助缩放,在x轴方向缩放半圆,实现类似的效果。

使用缩放模拟真实的月相变换效果

如下,通过一个圆(作为银白色的月亮),两个phase作为半圆并遮挡银色月亮,以缩放为原理,模拟实现月相变化的效果。

  1. 首先,绘制月亮,左右两个半圆phase,覆盖遮挡月亮。
  2. 右侧半圆缩放,显示出下方的右半侧月亮。(上玄月)
  3. 右侧半圆缩放到负值,转向左侧,颜色改为银白色,作为月亮左半侧的显示。盈凸(凸月)
  4. 右侧半圆完全位于左侧。将左侧半圆直接缩放到右侧,颜色为银白色,和右侧半圆(现在位于左侧),构成月亮,月亮变为黑色。(满月)
  5. 缩放左侧半圆,银色月亮逐渐变少。亏凸(凸月)
  6. 左侧半圆从右侧缩放至左侧,颜色变为黑色,逐渐覆盖左侧银白色。(下玄月)
  7. 左侧半圆完全位于左侧,月亮看不见。(晦日月亮)
  8. 从1开始重复。

利用了缩放范围phaseXScale从1~-1及从-1~1的循环过程。

// 月亮
let moon1 = new Ball({
   r: 80,
   x: width / 2,
   y: height / 2,
   fillStyle: "#c0c0c0",
   //strokeStyle:"rgba(192,192,192.6)"
});
let phase1 = new Ball({
   r: moon1.r,
   x: moon1.x,
   y: moon1.y,
   fillStyle: "#111",
   startArc:-Math.PI/2,
   endArc:Math.PI/2
});
let phase2 = new Ball({
   r: moon1.r,
   x: moon1.x,
   y: moon1.y,
   fillStyle: "#111",
   startArc:Math.PI/2,
   endArc:Math.PI*3/2
});

moon1.render(ctx);
phase1.render(ctx);
phase2.render(ctx);

let phaseXScale = 1; // -1 ~ 1
let pSpeed = 0.01;
(function phaseMove() {
   window.requestAnimationFrame(phaseMove);
   ctx.clearRect(0, 0, width, height);
   initNight(ctx);

   if(phaseXScale>=1){
       phaseSpeed = - pSpeed;
   }
   if(phaseXScale<=-1){
       phaseSpeed =  pSpeed;
       
   }

   phaseXScale += phaseSpeed;
   
   phase1.fillStyle=phaseXScale<0?"#c0c0c0":"#111";

   moon1.render(ctx);
   // 从右向左
   if(phaseSpeed<0){
       moon1.fillStyle= "#c0c0c0";
       phase1.scaleX = phaseXScale;
       phase2.fillStyle="#111";
       //左半边为#c0c0c0
       phase1.fillStyle=phaseXScale<0?"#c0c0c0":"#111";

       phase2.render(ctx);
       phase1.render(ctx);
       
   }
   else{
       moon1.fillStyle= "#111";
       phase2.scaleX = phaseXScale;
       phase1.fillStyle="#c0c0c0";
       //左半边为111
       phase2.fillStyle=phaseXScale<0?"#c0c0c0":"#111";
    
       phase1.render(ctx);
       phase2.render(ctx);
   }
})();

相对来说,线条还是比较柔顺,没有那么生硬。