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.
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.
이번에 접하게 된 문제는 문장에서 자주 등장하는 조사 '을', '를', '이', '가', '은', '는' 등 입니다.
이러한 경우 '{str}(은)는' 혹은 '{str}(이)가' 와 같이 표시하기도 하지만,
글을 읽는 사용자의 흐름을 끊을 수도 있겠다 싶어 해결책을 알아보았습니다.
이 문제를 해결하기 위해서는 컴퓨터가 문자를 어떻게 이해하는지 알아야합니다.
컴퓨터는 0과 1을 사용하여 모든 정보를 저장하기 때문에, 모든 정보를 2진수로 저장합니다.
그렇기에 문자를 문자 그대로 저장할 수 없습니다.
사용자가 (char)'A'를 저장하고자 하면 A => 2진수로 변환하는 과정이 필히 따라오게 됩니다.
이와 같은 과정에서 'A'를 나타내는 2진수 값을 저장 후 출력 시 2진수 => 'A' 로 다시 표현해주게 됩니다.
모든 컴퓨터 사용자 혹은 컴퓨터 생산 회사에서 이러한 값들을 표준 없이 사용한다면,
a 사에서 저장된 'A' 가 b 사의 컴퓨터에서는 '%'와 같이 전혀 다른 문자로 표현될 수 있고,
각 회사가 서로 정보를 공유하였다고 해도 a 사의 컴퓨터의 데이터를 b 사에서 전송, 수신, 출력 중 인코딩, 디코딩 과정을 한 번은 더 거쳐야 하기에 굉장히 비효율 적입니다.
그래서 나온 것이 '아스키 코드(ASCII CODE)'입니다.
아스키 코드는 위와 같은 혼란을 없애기 위해 미국 표준협회(ANSI)에서 128개의 문자에 대하여 문자-숫자 1:1로 대응되는 (char-1byte) 테이블을 정의 했습니다.
아스키코드로 'A'는 (2진수)0100 0001, (10진수)65 가 됩니다.
따라서 char 변수형은 '아스키 코드 값' 을 저장하고, 출력만 '문자'로 해줬습니다.
하지만, 컴퓨터의 보급과 범용화 과정에서 각 나라의 언어별로 필요한 문자들을 표현해야 했고, 일부 제어문자(특수문자)와 영어만을 표현하는 1Byte의 ASCII 코드만으로는 표현할 길이 없었습니다. 이를 해결하기 위해 '유니코드(UNICODE)'가 나왔습니다.
컴퓨터의 보급과 범용화 과정에서 늘어난 것은 표현 가능해야 할 '문자'들의 수 만이 아니라, 사용 가능한 컴퓨터의 자원 과 성능 또한 늘어났습니다. '유니코드(UNICODE)'는 2Byte의 용량을 시작으로, 현재는 4Byte(약 42억자)를 저장할 수 있다. 이에 따라 유니코드를 8비트 수의 집합으로 나타내는 UTF-8이나, 16비트 수의 집합으로 나타내는 UTF-16 등이 파생되었습니다.
웹 2D 게임엔진을 개발 중, 좀 더 풍부한 시각적 요소를 추가하기 위해 그림자, 빛, 날씨 효과 등의 요소가 필요할 것으로 판단하여 새로운 이펙트효과를 개발하는 과정에서 배우고 적용한 내용들을 기록해 볼까 합니다.
제가 공부하고 이해했던 방식을 역추적해 작성되어 정보가 정확하지 않을 수 있습니다.
또한,
태양의 고도, 남중고도, 지구와의 거리, 날씨 등을 모두 고려하여 그림자를 표현하기에 js 를 기반으로 웹에서 실행시키는 계산량이 너무 많을 것 같아 모든 변수를 감안해 시뮬레이션하기 보다는 적당히 사실 적으로만 보이면 될 듯 싶어 태양의 고도, 객체의 크기만을 고려하여 적용했습니다.
본론
0~360° 까지의 sin 함수를 살펴보면
0~180° 까지 양수,
180~360° 까지 음수를 반환 합니다.
다시, 그림자를 생각해보면
태양이 90° 에 뜨는 시간에 그림자는 가장 짧아지고,
짧아진 길이만큼 그림자는 짙어집니다.
그래서 sin 값을 그림자의 길이와 투명도에 적용하고,
sin 값이 양수일 때는 낮을, 음수일 때는 밤이라고 가정하여
낮에만 오브젝트의 그림자를 그리고, 밤에는 전체 면적을 어둡게 하였습니다.
이제 테스트 코드를 작성해봅니다.
처음부터 스프라이트에 따른 그림자를 그리기 보다는 사각형 도형을 통하여 그림자를 표현해 수식을 완성해 보았습니다.