Celestial Programming : Moon Phase Image Rendering

Rigorous

For those not wanting to settle for an approximation, rendering a photorealistic, properly shaded (with mountains and craters having shadows) image is pretty easy with existing tools like Blender, and NASA's CGI Moon Kit. Since tutorials on 3D rendering are available elsewhere, this will focus on an approximate solution which generally looks right.

Approximate

An approximate method is to imagine a plane intersecting the center of the Moon, perpendicular to the direction of the Sun. As the phase changes, the plane is rotated about the Y axis. To keep things simple, we can ignore any perspective distortion (e.g. ignore the Z coordinate), the Y coordinate won't change, leaving only the X coordinate to be computed, and the rotation matrix simplifies to: x=rcosθ x=r \cos \theta Where θ \theta is the is the phase from 0° (new moon) to 360°, and r is the radius of the full sphere.

Since the sun is an approximate .5° sphere as viewed from the moon, the terminator is not a hard edge. Additionally, the moon's mountains and craters will also soften the transition from light to dark, so the code below creats a 2° gradient between the light and dark regions to provide a more realistic effect.

Moon Image Credit: NASA

'use strict';
//Greg Miller (gmiller@gregmiller.net) 2023
//Released as public domain
//http://www.celestialprogramming.com/

const rad=Math.PI/180;

const canvas = document.getElementById("canvas");
const w=canvas.width;
const h=canvas.height;
const ctx = canvas.getContext("2d");
const r=w/2*.91;
const gradient=2;

function getX(phase,angle){
    const f=Math.cos(phase*rad);

    let x;
    const cosi=Math.cos(angle*rad);
    x=f*r*cosi+w/2;

    if((phase<=180 && cosi<0) || (phase>180 && cosi>0)){
        x=r*cosi+w/2;
    }
    return x;
}

function drawDarkSide(phase){
    const gradientPoints=[];

    let x=getX(phase-gradient,0);
    let y=r*Math.sin(0)+h/2;

    ctx.beginPath();
    ctx.moveTo(x,y);

    for(let i=0;i<=360;i+=1){
        x=getX(phase+gradient,i);
        let x2=getX(phase-gradient,i);

        if(phase>180){
            const temp=x;
            x=x2;
            x2=temp; 
        }
        y=r*Math.sin(i*rad)+h/2;

        gradientPoints.push([x,x2,y,phase]);

        ctx.lineTo(x,y+1);
    }

    ctx.closePath();
    ctx.fill();
    return gradientPoints;
}

function fillGradient(points){
    if(Math.abs(points[0][3]-180)<=gradient){
        return;
    }
    for(let i=0;i<points.length-1;i++){
        let x1=points[i][0];
        let x2=points[i][1];
        let y1=points[i][2];
        let y2=points[i+1][2];

        if(points[i][3]>180){
            x1++;
        }

        const g=ctx.createLinearGradient(x1,y1,x2,y2);
        g.addColorStop(1,"#00000000");
        g.addColorStop(0,"#000000ff");
        ctx.fillStyle=g;

        ctx.beginPath();
        ctx.moveTo(x1,y1);
        ctx.lineTo(x2,y1);
        ctx.lineTo(x2,y2);
        ctx.lineTo(x1,y2);
        ctx.closePath();
        ctx.fill();

    }
}

function drawPhase(phase){
    ctx.fillStyle="#000000cc";
    const gradientPoints=drawDarkSide(phase);
    fillGradient(gradientPoints);
}

function display(phase){
    ctx.drawImage(document.getElementById("moon"), 0, 0);

    drawPhase(phase);
}

function animate(){
    phase=(phase+1)%360;
    display(phase);
    window.setTimeout(animate,10);
}

let phase=0;
animate();