481 lines
18 KiB
HTML
481 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Raycasting Renderer</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: #222;
|
|
color: white;
|
|
font-family: monospace;
|
|
}
|
|
canvas {
|
|
border: 1px solid #444;
|
|
display: block;
|
|
margin: 10px 0;
|
|
}
|
|
.controls {
|
|
margin: 10px 0;
|
|
}
|
|
button {
|
|
margin: 5px;
|
|
padding: 5px 10px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>Raycasting Renderer with DDA</h2>
|
|
<canvas id="screen" width="640" height="400"></canvas>
|
|
<div class="controls">
|
|
<button onclick="moveForward()">Forward (W)</button>
|
|
<button onclick="moveBackward()">Backward (S)</button>
|
|
<button onclick="turnLeft()">Turn Left (A)</button>
|
|
<button onclick="turnRight()">Turn Right (D)</button>
|
|
</div>
|
|
<p>Use W/A/S/D keys or buttons to move</p>
|
|
|
|
<script>
|
|
// Create textures
|
|
function createWallTexture() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 64;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Brick pattern
|
|
ctx.fillStyle = '#8B4513';
|
|
ctx.fillRect(0, 0, 64, 64);
|
|
ctx.strokeStyle = '#654321';
|
|
ctx.lineWidth = 2;
|
|
|
|
for (let y = 0; y < 64; y += 8) {
|
|
for (let x = 0; x < 64; x += 16) {
|
|
const offset = (y % 16 === 0) ? 0 : 8;
|
|
ctx.strokeRect(x + offset, y, 16, 8);
|
|
}
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
function createFloorTexture() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 64;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Checkerboard pattern
|
|
for (let y = 0; y < 64; y += 8) {
|
|
for (let x = 0; x < 64; x += 8) {
|
|
ctx.fillStyle = ((x + y) / 8) % 2 === 0 ? '#555' : '#333';
|
|
ctx.fillRect(x, y, 8, 8);
|
|
}
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
function createCeilingTexture() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 64;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Wood pattern
|
|
ctx.fillStyle = '#4A3020';
|
|
ctx.fillRect(0, 0, 64, 64);
|
|
ctx.strokeStyle = '#3A2010';
|
|
|
|
for (let x = 0; x < 64; x += 4) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x + Math.random() * 2 - 1, 64);
|
|
ctx.stroke();
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* Main raycasting renderer function
|
|
* @param {CanvasRenderingContext2D} screenCtx - The screen to render to
|
|
* @param {boolean[][]} worldGrid - 2D array of walls (true = wall, false = empty)
|
|
* @param {number} playerX - Player X position in world units
|
|
* @param {number} playerY - Player Y position in world units
|
|
* @param {number} playerAngle - Player viewing angle in radians
|
|
* @param {CanvasRenderingContext2D} wallTextureCtx - Wall texture context
|
|
* @param {CanvasRenderingContext2D} floorTextureCtx - Floor texture context
|
|
* @param {CanvasRenderingContext2D} ceilingTextureCtx - Ceiling texture context
|
|
* @param {number} fov - Field of view in radians (default: 60 degrees)
|
|
*/
|
|
function renderRaycastScene(
|
|
screenCtx,
|
|
worldGrid,
|
|
playerX,
|
|
playerY,
|
|
playerAngle,
|
|
wallTextureCtx,
|
|
floorTextureCtx,
|
|
ceilingTextureCtx,
|
|
fov = Math.PI / 3
|
|
) {
|
|
const screenWidth = screenCtx.canvas.width;
|
|
const screenHeight = screenCtx.canvas.height;
|
|
const maxViewDistance = 5;
|
|
|
|
// Clear screen with near-black
|
|
screenCtx.fillStyle = '#0a0a0a';
|
|
screenCtx.fillRect(0, 0, screenWidth, screenHeight);
|
|
|
|
// Get image data for direct pixel manipulation
|
|
const imageData = screenCtx.getImageData(0, 0, screenWidth, screenHeight);
|
|
const pixels = imageData.data;
|
|
|
|
// Get texture data
|
|
const wallTexData = wallTextureCtx.getImageData(0, 0, 64, 64);
|
|
const floorTexData = floorTextureCtx.getImageData(0, 0, 64, 64);
|
|
const ceilingTexData = ceilingTextureCtx.getImageData(0, 0, 64, 64);
|
|
|
|
// Cast one ray per screen column
|
|
for (let screenX = 0; screenX < screenWidth; screenX++) {
|
|
// Calculate ray angle
|
|
// Map screen X to angle within FOV
|
|
const screenNormalized = (screenX / screenWidth) - 0.5;
|
|
const rayAngle = playerAngle + (screenNormalized * fov);
|
|
|
|
// Cast ray using DDA
|
|
const rayResult = castRayDDA(
|
|
playerX,
|
|
playerY,
|
|
rayAngle,
|
|
worldGrid,
|
|
maxViewDistance
|
|
);
|
|
|
|
if (rayResult.hit) {
|
|
// Correct for fish-eye effect
|
|
const angleDifference = rayAngle - playerAngle;
|
|
const correctedDistance = rayResult.distance * Math.cos(angleDifference);
|
|
|
|
// Calculate wall height on screen
|
|
const wallHeight = Math.floor(screenHeight / correctedDistance);
|
|
const wallTop = Math.floor((screenHeight - wallHeight) / 2);
|
|
const wallBottom = wallTop + wallHeight;
|
|
|
|
// Draw ceiling
|
|
for (let screenY = 0; screenY < wallTop; screenY++) {
|
|
drawCeilingPixel(
|
|
pixels,
|
|
screenX,
|
|
screenY,
|
|
screenWidth,
|
|
screenHeight,
|
|
playerX,
|
|
playerY,
|
|
playerAngle,
|
|
rayAngle,
|
|
ceilingTexData
|
|
);
|
|
}
|
|
|
|
// Draw wall
|
|
for (let screenY = wallTop; screenY < wallBottom; screenY++) {
|
|
if (screenY >= 0 && screenY < screenHeight) {
|
|
const textureY = ((screenY - wallTop) / wallHeight) * 64;
|
|
const textureX = rayResult.wallTextureX * 64;
|
|
|
|
const texIndex = (Math.floor(textureY) * 64 + Math.floor(textureX)) * 4;
|
|
const pixelIndex = (screenY * screenWidth + screenX) * 4;
|
|
|
|
// Apply distance fog
|
|
const darkness = 1 - (rayResult.distance / maxViewDistance);
|
|
|
|
pixels[pixelIndex] = wallTexData.data[texIndex] * darkness;
|
|
pixels[pixelIndex + 1] = wallTexData.data[texIndex + 1] * darkness;
|
|
pixels[pixelIndex + 2] = wallTexData.data[texIndex + 2] * darkness;
|
|
pixels[pixelIndex + 3] = 255;
|
|
}
|
|
}
|
|
|
|
// Draw floor
|
|
for (let screenY = wallBottom; screenY < screenHeight; screenY++) {
|
|
drawFloorPixel(
|
|
pixels,
|
|
screenX,
|
|
screenY,
|
|
screenWidth,
|
|
screenHeight,
|
|
playerX,
|
|
playerY,
|
|
playerAngle,
|
|
rayAngle,
|
|
floorTexData
|
|
);
|
|
}
|
|
} else {
|
|
// No wall hit - draw only ceiling and floor fading to black
|
|
for (let screenY = 0; screenY < screenHeight / 2; screenY++) {
|
|
drawCeilingPixel(
|
|
pixels,
|
|
screenX,
|
|
screenY,
|
|
screenWidth,
|
|
screenHeight,
|
|
playerX,
|
|
playerY,
|
|
playerAngle,
|
|
rayAngle,
|
|
ceilingTexData,
|
|
true // fade to black
|
|
);
|
|
}
|
|
|
|
for (let screenY = screenHeight / 2; screenY < screenHeight; screenY++) {
|
|
drawFloorPixel(
|
|
pixels,
|
|
screenX,
|
|
screenY,
|
|
screenWidth,
|
|
screenHeight,
|
|
playerX,
|
|
playerY,
|
|
playerAngle,
|
|
rayAngle,
|
|
floorTexData,
|
|
true // fade to black
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Put image data back
|
|
screenCtx.putImageData(imageData, 0, 0);
|
|
}
|
|
|
|
/**
|
|
* Cast a single ray using DDA algorithm
|
|
*/
|
|
function castRayDDA(startX, startY, angle, worldGrid, maxDistance) {
|
|
// Calculate ray direction from angle
|
|
const dirX = Math.cos(angle);
|
|
const dirY = Math.sin(angle);
|
|
|
|
// Current position
|
|
let x = startX;
|
|
let y = startY;
|
|
|
|
// Calculate step sizes for DDA
|
|
const stepSize = 0.01; // Small step for accuracy
|
|
const deltaX = dirX * stepSize;
|
|
const deltaY = dirY * stepSize;
|
|
|
|
let distance = 0;
|
|
|
|
while (distance < maxDistance) {
|
|
// Move along the ray
|
|
x += deltaX;
|
|
y += deltaY;
|
|
distance += stepSize;
|
|
|
|
// Check grid cell
|
|
const gridX = Math.floor(x);
|
|
const gridY = Math.floor(y);
|
|
|
|
// Check bounds
|
|
if (gridY < 0 || gridY >= worldGrid.length ||
|
|
gridX < 0 || gridX >= worldGrid[0].length) {
|
|
break;
|
|
}
|
|
|
|
// Check for wall hit
|
|
if (worldGrid[gridY][gridX]) {
|
|
// Calculate texture coordinate
|
|
const xFrac = x - gridX;
|
|
const yFrac = y - gridY;
|
|
|
|
let wallTextureX;
|
|
// Determine which wall face was hit
|
|
if (Math.abs(xFrac - 0) < 0.01) {
|
|
wallTextureX = yFrac; // West wall
|
|
} else if (Math.abs(xFrac - 1) < 0.01) {
|
|
wallTextureX = 1 - yFrac; // East wall
|
|
} else if (Math.abs(yFrac - 0) < 0.01) {
|
|
wallTextureX = 1 - xFrac; // North wall
|
|
} else {
|
|
wallTextureX = xFrac; // South wall
|
|
}
|
|
|
|
return {
|
|
hit: true,
|
|
distance: distance,
|
|
hitX: x,
|
|
hitY: y,
|
|
wallTextureX: wallTextureX
|
|
};
|
|
}
|
|
}
|
|
|
|
return { hit: false, distance: maxDistance };
|
|
}
|
|
|
|
/**
|
|
* Draw a floor pixel with texture mapping
|
|
*/
|
|
function drawFloorPixel(pixels, screenX, screenY, screenWidth, screenHeight,
|
|
playerX, playerY, playerAngle, rayAngle, floorTexData, fadeToBlack = false) {
|
|
// Calculate floor position using reverse projection
|
|
const screenCenterY = screenHeight / 2;
|
|
const deltaY = screenY - screenCenterY;
|
|
|
|
if (deltaY <= 0) return;
|
|
|
|
// Calculate distance to floor point
|
|
const rowDistance = (screenHeight / 2) / deltaY;
|
|
|
|
// Calculate world coordinates
|
|
const worldX = playerX + rowDistance * Math.cos(rayAngle);
|
|
const worldY = playerY + rowDistance * Math.sin(rayAngle);
|
|
|
|
// Get texture coordinates (repeating)
|
|
const texX = Math.floor(Math.abs(worldX * 64) % 64);
|
|
const texY = Math.floor(Math.abs(worldY * 64) % 64);
|
|
|
|
const texIndex = (texY * 64 + texX) * 4;
|
|
const pixelIndex = (screenY * screenWidth + screenX) * 4;
|
|
|
|
// Apply distance fog
|
|
const darkness = fadeToBlack ? 0.1 : Math.max(0.1, 1 - (rowDistance / 5));
|
|
|
|
pixels[pixelIndex] = floorTexData.data[texIndex] * darkness;
|
|
pixels[pixelIndex + 1] = floorTexData.data[texIndex + 1] * darkness;
|
|
pixels[pixelIndex + 2] = floorTexData.data[texIndex + 2] * darkness;
|
|
pixels[pixelIndex + 3] = 255;
|
|
}
|
|
|
|
/**
|
|
* Draw a ceiling pixel with texture mapping
|
|
*/
|
|
function drawCeilingPixel(pixels, screenX, screenY, screenWidth, screenHeight,
|
|
playerX, playerY, playerAngle, rayAngle, ceilingTexData, fadeToBlack = false) {
|
|
// Calculate ceiling position using reverse projection
|
|
const screenCenterY = screenHeight / 2;
|
|
const deltaY = screenCenterY - screenY;
|
|
|
|
if (deltaY <= 0) return;
|
|
|
|
// Calculate distance to ceiling point
|
|
const rowDistance = (screenHeight / 2) / deltaY;
|
|
|
|
// Calculate world coordinates
|
|
const worldX = playerX + rowDistance * Math.cos(rayAngle);
|
|
const worldY = playerY + rowDistance * Math.sin(rayAngle);
|
|
|
|
// Get texture coordinates (repeating)
|
|
const texX = Math.floor(Math.abs(worldX * 64) % 64);
|
|
const texY = Math.floor(Math.abs(worldY * 64) % 64);
|
|
|
|
const texIndex = (texY * 64 + texX) * 4;
|
|
const pixelIndex = (screenY * screenWidth + screenX) * 4;
|
|
|
|
// Apply distance fog
|
|
const darkness = fadeToBlack ? 0.1 : Math.max(0.1, 1 - (rowDistance / 5));
|
|
|
|
pixels[pixelIndex] = ceilingTexData.data[texIndex] * darkness;
|
|
pixels[pixelIndex + 1] = ceilingTexData.data[texIndex + 1] * darkness;
|
|
pixels[pixelIndex + 2] = ceilingTexData.data[texIndex + 2] * darkness;
|
|
pixels[pixelIndex + 3] = 255;
|
|
}
|
|
|
|
// Demo setup
|
|
const canvas = document.getElementById('screen');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Create world grid (true = wall, false = empty)
|
|
const worldGrid = [
|
|
[true, true, true, true, true, true, true, true, true, true],
|
|
[true, false, false, false, false, false, false, false, false, true],
|
|
[true, false, true, false, false, false, false, true, false, true],
|
|
[true, false, false, false, false, false, false, false, false, true],
|
|
[true, false, false, false, true, true, false, false, false, true],
|
|
[true, false, false, false, true, true, false, false, false, true],
|
|
[true, false, false, false, false, false, false, false, false, true],
|
|
[true, false, true, false, false, false, false, true, false, true],
|
|
[true, false, false, false, false, false, false, false, false, true],
|
|
[true, true, true, true, true, true, true, true, true, true]
|
|
];
|
|
|
|
// Player state
|
|
let playerX = 2.5;
|
|
let playerY = 2.5;
|
|
let playerAngle = 0;
|
|
|
|
// Create textures
|
|
const wallTexture = createWallTexture();
|
|
const floorTexture = createFloorTexture();
|
|
const ceilingTexture = createCeilingTexture();
|
|
|
|
// Render function
|
|
function render() {
|
|
renderRaycastScene(
|
|
ctx,
|
|
worldGrid,
|
|
playerX,
|
|
playerY,
|
|
playerAngle,
|
|
wallTexture,
|
|
floorTexture,
|
|
ceilingTexture
|
|
);
|
|
}
|
|
|
|
// Movement functions
|
|
function moveForward() {
|
|
const newX = playerX + Math.cos(playerAngle) * 0.2;
|
|
const newY = playerY + Math.sin(playerAngle) * 0.2;
|
|
|
|
if (!worldGrid[Math.floor(newY)][Math.floor(newX)]) {
|
|
playerX = newX;
|
|
playerY = newY;
|
|
render();
|
|
}
|
|
}
|
|
|
|
function moveBackward() {
|
|
const newX = playerX - Math.cos(playerAngle) * 0.2;
|
|
const newY = playerY - Math.sin(playerAngle) * 0.2;
|
|
|
|
if (!worldGrid[Math.floor(newY)][Math.floor(newX)]) {
|
|
playerX = newX;
|
|
playerY = newY;
|
|
render();
|
|
}
|
|
}
|
|
|
|
function turnLeft() {
|
|
playerAngle -= 0.1;
|
|
render();
|
|
}
|
|
|
|
function turnRight() {
|
|
playerAngle += 0.1;
|
|
render();
|
|
}
|
|
|
|
// Keyboard controls
|
|
document.addEventListener('keydown', (e) => {
|
|
switch(e.key.toLowerCase()) {
|
|
case 'w': moveForward(); break;
|
|
case 's': moveBackward(); break;
|
|
case 'a': turnLeft(); break;
|
|
case 'd': turnRight(); break;
|
|
}
|
|
});
|
|
|
|
// Initial render
|
|
render();
|
|
</script>
|
|
</body>
|
|
</html> |