vanilla js ] drop sprite shadows on canvas
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.