ALGORITHM

vanilla js ] drop sprite shadows on canvas

김종상 2024. 4. 9. 09:23

Introduction


While developing a 2D web game engine, I decided that adding more rich visual elements such as shadows, light, and weather effects would be necessary. During the process of developing these new effect features, I have documented what I learned and applied.

Please note that the information written here is reconstructed based on how I studied and understood it, so it might not be entirely accurate.

Moreover,

Considering the sun's altitude, solar noon altitude, distance from Earth, and weather all together to depict shadows would require too much computational power when run on the web using JavaScript. Thus, instead of simulating all variables, I decided it would be sufficient to make it look reasonably realistic by considering only the sun's altitude and the object's size.

 

Main Content


 

 

When examining the sin function from 0° to 360°,

0° to 180° returns a positive value,

180° to 360° returns a negative value.

Thinking back to shadows,

When the sun is at 90°, the shadow is at its shortest,

And as the shadow shortens, it becomes denser.

Therefore, I applied the sin value to the length and opacity of the shadow, and

Assumed day when the sin value is positive and night when it is negative,

Only drawing the object's shadow during the day, and darkening the entire area at night.

Now let's write a test code.

Instead of drawing shadows according to sprites from the start, I completed the formula by expressing shadows through rectangular shapes.

 

 

RESULT

 

 

HTML

<body>
<canvas style="width: 100vw; height: 100vh; background-color: white;"></canvas>
</body>

 

 

JS

const pi = Math.PI;
const canv = document.querySelector("canvas");
const ctx = canv.getContext("2d", { willReadFrequently: true });
const dpr = window.devicePixelRatio;

const canvasWidth = window.innerWidth;
const canvasHeight = window.innerHeight;

canv.style.width = canvasWidth + 'px';
canv.style.height = canvasHeight + 'px';
canv.width = canvasWidth * dpr;
canv.height = canvasHeight * dpr;

// Converts degrees to radians
/*
While we are accustomed to using degrees,
trigonometric functions must be calculated in radians.
*/
function getDegree(deg) {
    return deg * pi / 180;
}

function GlobalLight(a) {            
    // angle
    this.a = a;
    // brightness
    this.b = 1;

    this.dropShadow = () => {
        const sin = Math.sin(getDegree(this.a));
        const cos = Math.cos(getDegree(this.a));
        for (const p of rList) {
            // Calculate the brightness of the shadow.
            /*
            As previously mentioned, the intensity should increase as the shadow shortens,
            and decrease as it lengthens, hence the multiplication by the sin value.
            */
            ctx.fillStyle = `rgba(0,0,0,${0.4 * Math.sin(getDegree(this.a))})`;
            // Draw the shadow.
            ctx.beginPath();
            // The starting point of the shadow is at the bottom of the object, so it starts from the bottom left of the object.
            ctx.moveTo(p.x, p.y + p.height);                    
            // Bottom left -> Bottom right
            ctx.lineTo(p.x + p.width, p.y + p.height);
            // From the unit circle, the coordinate values for an angle Θ are (x, y) -> (cos(Θ), sin(Θ)),
            // so we draw a line to (cos(Θ) * height, sin(Θ) * height).
            ctx.lineTo(p.x + p.width + p.height * cos, p.y + p.height + p.height * sin);
            // - width
            ctx.lineTo(p.x + p.height * cos, p.y + p.height + p.height * sin);
            // Return to the starting point and fill it.
            ctx.lineTo(p.x, p.y + p.height);
            ctx.fill();                    
        }
    };
}

function Rect(x, y, width, height) {
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;

    this.draw = () => {
        ctx.fillStyle = "rgba(200,200,120,1)";
        ctx.fillRect(this.x, this.y, this.width, this.height);
        ctx.fillStyle = "Black";
    };
}
const globalLight = new GlobalLight(0);
const rList = [];
rList.push(new Rect(50, 50, 50, 50));
rList.push(new Rect(50, 150, 75, 50));
rList.push(new Rect(50, 250, 50, 75));

// Function to be called every 16ms
const repeatOften = () => {
    const st = new Date();
    ctx.clearRect(0, 0, canvasWidth, canvasHeight);
    globalLight.a += 0.5;
    if (globalLight.a > 360) {
        globalLight.a = 0;
    }
    globalLight.dropShadow();
    for (const p of rList) {
        p.draw();
    }

    // To smooth the transition between day and night, subtracting 0.3 from the sin value.
    globalLight.b = -0.5 * (Math.sin(getDegree(globalLight.a)) - 0.3);
    ctx.fillStyle = `rgba(0,0,0,${globalLight.b})`;
    ctx.fillRect(0, 0, canvasWidth, canvasHeight);
    const et = new Date();
    console.log(`${et - st}ms required`);
    setTimeout(repeatOften, 16 - (et - st));
};
repeatOften();

Based on the above, the application to sprites is as follows.


RESULT -> 03:00

 

 

JS

function character(img) {
    this.src = img; // The image source for the character
    this.x = 32; // Initial x position
    this.y = 32; // Initial y position
    this.height = this.src.height; // Height of the character, determined by the image height
    this.width = this.src.width; // Width of the character, determined by the image width
    this.init = () => { // Initializes the character's dimensions
        this.height = this.src.height;
        this.width = this.src.width;
    };
    this.draw = () => { // Draws the character on the canvas
        ctx.drawImage(this.src, this.x, this.y, this.width, this.height);
    };
    this.dropShadow = () => { // Calculates and draws the shadow based on the light source angle
        const sin = Math.sin(getDegree(gl.a)); // Sine of the global light angle
        const cos = Math.cos(getDegree(gl.a)); // Cosine of the global light angle
        let imgWidth = this.src.width; // Character image width
        let imgHeight = this.src.height; // Character image height
        let sX = this.x; // Shadow's starting x position
        let sY = this.y + imgHeight; // Shadow's starting y position
        ctx.fillStyle = `rgba(0,0,0,${0.7 * (-1 * sin)})`; // Sets the shadow's color and opacity
        if (gl.a > 180) return; // No shadow if the angle indicates it's "night"
        ctx.fillStyle = `rgba(0,0,0,${0.5 * sin})`; // Adjusts shadow color for "daytime"
        ctx.beginPath();
        let positionsArr = []; // Stores positions for drawing the shadow
        for (let i = imgHeight - 1; i >= 0; --i) { // Iterates over the height of the character image
            const tmpPositions = [];
            let flag = false;
            for (let k = imgWidth - 1; k >= 0; --k) { // Iterates over the width of the character image
                const iData = ctx.getImageData(this.x + k, this.y + i, 1, 1).data; // Gets pixel data to determine if a shadow should be cast
                const tmpX = sX + imgWidth - k - 1 + (imgWidth - i) * cos * 2 + cos;
                const tmpY = sY + (imgHeight - i) * (sin) - sin;
                if (iData[3] > 0) { // Checks alpha value to determine if pixel is opaque
                    if (!flag) {
                        flag = true;
                        tmpPositions.push({ x: tmpX, y: tmpY });
                    } else if (flag && tmpPositions.length >= 1) {
                        tmpPositions[1] = { x: tmpX, y: tmpY };
                        positionsArr.push(tmpPositions);
                    }
                } else {
                    if (flag && tmpPositions.length == 1) {
                        tmpPositions.push({ x: tmpX, y: tmpY });
                        positionsArr.push(tmpPositions);
                    }
                }
            }
        }
        // Draws the calculated shadow positions
        ctx.beginPath();
        positionsArr.forEach(position => {
            ctx.lineTo(position[0].x - 1, position[0].y);
        });
        for (let i = positionsArr.length - 1; i >= 0; --i) {
            ctx.lineTo(positionsArr[i][1].x - 1, positionsArr[i][1].y);
        }
        ctx.fill();
    };
}

function repeatOften() {
    const st = new Date(); // Start time for performance tracking
    ctx.clearRect(0, 0, rect.width, rect.height); // Clears the canvas for the next frame
    gl.a += 0.5; // Increment the global light angle to simulate time passing
    if (gl.a > 180) {
        gl.a = 0; // Reset the angle after a full "day"
    }
    c.draw(); // Draw the character
    c.dropShadow(); // Draw the character's shadow
    const et = new Date(); // End time for performance tracking
    console.log(`${et - st}ms required`); // Log the time taken to draw the frame
    setTimeout(repeatOften,16);
}

 

Conclusion


 

    • It seems that a lot of optimization will be necessary due to the high time complexity...
    • The above source code records the starting and ending points of each row of the img src before rendering, so objects that are empty in the middle are not rendered correctly.