Links:
Introduction
As a full-stack developer, I constantly seek out tasks and projects to keep my skills sharp and my curiosity satisfied. My latest adventure took me back to the 90s, to the world of 3D ray casting. Inspired by classics like Wolfenstein 3D, I set out to implement this technique in pure JavaScript, without relying on the 3D functions of the canvas context. This project turned into a fascinating journey through the intricacies of game development and browser performance.
The Challenge
Ray casting in the style of early 90s games involves casting rays from the playerโs perspective, one for each pixel horizontally across the screen. The rays move in intervals of one tile, creating a 3D effect. Despite having no background in game development, I was eager to tackle challenges such as sprite rendering, player movement, and interaction with the environment and moving objects.
Why JavaScript?
I chose to implement the project using pure JavaScript to see if modern browsers and computers could handle the intensive processing required for rendering game scenes. This approach meant manually processing and rendering each pixel, without relying on built-in 3D rendering functions. The question at the heart of the project was: Can modern web technology deliver acceptable performance for such a demanding task?
const ctx = canvas.getContext('2d', {
alpha: true,
willReadFrequently: true
});
The Journey
What began as a simple curiosity-driven project quickly grew into a full-fledged endeavor, consuming six months of my time. The challenge of optimizing performance was particularly engaging. I frequently revisited my code, eliminating redundant operations and optimizing calculations, especially those involving trigonometric functions. For instance, I used bitwise shifts instead of multiplying or dividing by 2, and bitwise |0 instead of rounding or using Math.floor(), to speed up computations.
Pixel-Level Control
For manipulating pixels, I used Uint32Array, which allowed me to write pixel states directly by index. This approach was effective because three bits represent color, and the fourth bit is alpha, enabling adjustments in pixel brightness and marking screen areas as occupied. This level of control was crucial for achieving the desired visual effects and performance.
const buf = new ArrayBuffer(height * width * 4);
const buf8 = new Uint8ClampedArray(buf);
const data = new Uint32Array(buf);
...
const alphaMask = 0x00ffffff | (light << 24);
const pixel = textureImageData[textureIndex];
data[dataIndex] = pixel & alphaMask;
Overcoming Mathematical Hurdles
One of the most daunting aspects was finding solutions and formulas for rendering horizontal surfaces such as floors, ceilings, and various beams. Without a background in mathematics or game development, this part of the project was particularly time-consuming. However, the result was a visually interesting and acceptable image that met my performance goals.
//// This's just an example. The actual code has been improved to reduce the number of calculations.
public getTileSpriteDataIndexBySideX_positive(
ray: Ray,
offset: number,
textureData: TextureData
): number {
const { width, height } = textureData;
const fixSinAbs = Math.abs(Math.sin(ray.angle)) / ray.fixDistance;
const factY = textureData.height * rayAngle.fixSinAbs
const diff = Math.abs(ray.fixedDistance - ray.distance);
const fixedCos = Math.cos(ray.angle) / ray.fixDistance;
const fixedCosDiff = fixedCos * diff;
const offsetX = offset - fixedCosDiff + (fixedCosDiff | 0) + 1;
const spriteOffsetX = ((offsetX - (offsetX | 0)) * width) | 0;
const spriteOffsetY = (diff * factY) | 0;
const fixedX = height - mod(spriteOffsetY, height) - 1;
return Math.imul(fixedX, width) + spriteOffsetX;
}
Not Just JavaScript
Although I initially aimed to use pure JavaScript, I eventually incorporated TypeScript to manage complex code structures more effectively. Additionally, I used the Vue framework to bundle everything together, ensuring a smoother development process and more maintainable codebase.
export type Tile = {
bottom: number;
texture?: Texture;
name?: string;
};
export type Wall = {
public top: number;
public bottom: number;
public texture?: Texture;
public name?: string;
};
export type MapItem = {
walls: Wall[];
tiles: Tile[];
mirror?: boolean;
stopRay: boolean;
}
Performance Insights
The project also served as an insightful browser performance test. On my laptop with an i7โ10510U processor, Chrome emerged as the most efficient browser, followed by Edge, Firefox, and Safari. These findings highlighted the varying capabilities of different browsers when handling intensive JavaScript computations.
Future Prospects
While the current version of the game is a proof of concept, I have plans to enhance its playability. Future updates may include adding player goals, sound effects, and even multiplayer capabilities. The project has provided a solid foundation for exploring these possibilities.
Conclusion
This journey into 90s 3D ray casting has been both challenging and rewarding. It started as a pet project driven by curiosity and evolved into a deep dive into classic game programming techniques and modern web performance. I invite you to check out the game and explore its source code. Your feedback and contributions are welcome as I continue to refine and expand this project.
Links:
Thank you for reading, and happy coding!
Top comments (0)