Abstracted more functionality into the base rasterizer

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-16 10:42:09 +01:00
parent 4f790d8fb7
commit f7ca65ab6e
5 changed files with 836 additions and 667 deletions

View File

@@ -2,430 +2,187 @@ import 'dart:math' as math;
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
class SoftwareRasterizer implements Rasterizer {
// Pre-calculated VGA colors for ceiling and floor
final int ceilingColor = ColorPalette.vga32Bit[25];
final int floorColor = ColorPalette.vga32Bit[29];
class SoftwareRasterizer extends Rasterizer {
late FrameBuffer _buffer;
late WolfEngine _engine;
// Intercept the base render call to store our references
@override
dynamic render(WolfEngine engine, FrameBuffer buffer) {
_engine = engine;
_buffer = buffer;
return super.render(engine, buffer);
}
@override
void render(WolfEngine engine, FrameBuffer buffer) {
_clearScreen(buffer);
void prepareFrame(WolfEngine engine) {
// Top half is ceiling color (25), bottom half is floor color (29)
int ceilingColor = ColorPalette.vga32Bit[25];
int floorColor = ColorPalette.vga32Bit[29];
List<double> zBuffer = List.filled(buffer.width, 0.0);
_castWalls(engine, buffer, zBuffer);
_castSprites(engine, buffer, zBuffer);
// NEW: Draw the weapon on top of the 3D world
_drawWeapon(engine, buffer);
// NEW: Apply the full-screen damage tint last
_drawDamageFlash(engine, buffer);
_drawHud(engine, buffer);
}
void _clearScreen(FrameBuffer buffer) {
const int viewHeight = 160;
int halfScreen = (buffer.width * viewHeight) ~/ 2;
// Only clear the top 160 rows!
buffer.pixels.fillRange(0, halfScreen, ceilingColor);
buffer.pixels.fillRange(halfScreen, buffer.width * viewHeight, floorColor);
}
void _castWalls(WolfEngine engine, FrameBuffer buffer, List<double> zBuffer) {
final double fov = math.pi / 3;
final Player player = engine.player;
final SpriteMap map = engine.currentLevel;
// Fetch dynamic states from the managers
final Map<String, double> doorOffsets = engine.doorManager
.getOffsetsForRenderer();
final Pushwall? activePushwall = engine.pushwallManager.activePushwall;
Coordinate2D dir = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
Coordinate2D plane = Coordinate2D(-dir.y, dir.x) * math.tan(fov / 2);
for (int x = 0; x < buffer.width; x++) {
double cameraX = 2 * x / buffer.width - 1.0;
Coordinate2D rayDir = dir + (plane * cameraX);
int mapX = player.x.toInt();
int mapY = player.y.toInt();
double sideDistX;
double sideDistY;
double deltaDistX = (rayDir.x == 0) ? 1e30 : (1.0 / rayDir.x).abs();
double deltaDistY = (rayDir.y == 0) ? 1e30 : (1.0 / rayDir.y).abs();
double perpWallDist = 0.0;
int stepX;
int stepY;
bool hit = false;
bool hitOutOfBounds = false;
int side = 0;
int hitWallId = 0;
double textureOffset = 0.0;
bool customDistCalculated = false;
Set<String> ignoredDoors = {};
if (rayDir.x < 0) {
stepX = -1;
sideDistX = (player.x - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1.0 - player.x) * deltaDistX;
for (int y = 0; y < viewHeight; y++) {
int color = (y < viewHeight / 2) ? ceilingColor : floorColor;
for (int x = 0; x < width; x++) {
_buffer.pixels[y * width + x] = color;
}
if (rayDir.y < 0) {
stepY = -1;
sideDistY = (player.y - mapY) * deltaDistY;
} else {
stepY = 1;
sideDistY = (mapY + 1.0 - player.y) * deltaDistY;
}
// --- DDA LOOP ---
while (!hit) {
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
if (mapY < 0 ||
mapY >= map.length ||
mapX < 0 ||
mapX >= map[0].length) {
hit = true;
hitOutOfBounds = true;
} else if (map[mapY][mapX] > 0) {
String mapKey = '$mapX,$mapY';
// DOOR LOGIC
if (map[mapY][mapX] >= 90 && !ignoredDoors.contains(mapKey)) {
double currentOffset = doorOffsets[mapKey] ?? 0.0;
if (currentOffset > 0.0) {
double perpWallDistTemp = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
double wallXTemp = (side == 0)
? player.y + perpWallDistTemp * rayDir.y
: player.x + perpWallDistTemp * rayDir.x;
wallXTemp -= wallXTemp.floor();
if (wallXTemp < currentOffset) {
ignoredDoors.add(mapKey);
continue;
}
}
hit = true;
hitWallId = map[mapY][mapX];
textureOffset = currentOffset;
}
// PUSHWALL LOGIC
else if (activePushwall != null &&
mapX == activePushwall.x &&
mapY == activePushwall.y) {
hit = true;
hitWallId = map[mapY][mapX];
double pOffset = activePushwall.offset;
int pDirX = activePushwall.dirX;
int pDirY = activePushwall.dirY;
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
if (side == 0 && pDirX != 0) {
if (pDirX == stepX) {
double intersect = perpWallDist + pOffset * deltaDistX;
if (intersect < sideDistY) {
perpWallDist = intersect;
} else {
side = 1;
perpWallDist = sideDistY - deltaDistY;
}
} else {
perpWallDist -= (1.0 - pOffset) * deltaDistX;
}
} else if (side == 1 && pDirY != 0) {
if (pDirY == stepY) {
double intersect = perpWallDist + pOffset * deltaDistY;
if (intersect < sideDistX) {
perpWallDist = intersect;
} else {
side = 0;
perpWallDist = sideDistX - deltaDistX;
}
} else {
perpWallDist -= (1.0 - pOffset) * deltaDistY;
}
} else {
double wallFraction = (side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x;
wallFraction -= wallFraction.floor();
if (side == 0) {
if (pDirY == 1 && wallFraction < pOffset) hit = false;
if (pDirY == -1 && wallFraction > (1.0 - pOffset)) hit = false;
if (hit) textureOffset = pOffset * pDirY;
} else {
if (pDirX == 1 && wallFraction < pOffset) hit = false;
if (pDirX == -1 && wallFraction > (1.0 - pOffset)) hit = false;
if (hit) textureOffset = pOffset * pDirX;
}
}
if (!hit) continue;
customDistCalculated = true;
}
// STANDARD WALL
else {
hit = true;
hitWallId = map[mapY][mapX];
}
}
}
if (hitOutOfBounds) continue;
if (!customDistCalculated) {
if (side == 0) {
perpWallDist = (sideDistX - deltaDistX);
} else {
perpWallDist = (sideDistY - deltaDistY);
}
}
// Log the distance so sprites know if they are occluded
zBuffer[x] = perpWallDist;
double wallX = (side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x;
wallX -= wallX.floor();
_drawTexturedColumn(
x,
perpWallDist,
wallX,
side,
hitWallId,
engine.data.walls, // Wall textures
textureOffset,
buffer,
player.angle,
);
}
}
void _drawTexturedColumn(
@override
void drawWallColumn(
int x,
double distance,
double wallX,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
int hitWallId,
List<Sprite> textures,
double textureOffset,
FrameBuffer buffer,
double playerAngle,
) {
const int viewHeight = 160;
if (distance <= 0.01) distance = 0.01;
int lineHeight = (viewHeight / distance).toInt();
int drawStart = -lineHeight ~/ 2 + viewHeight ~/ 2;
if (drawStart < 0) drawStart = 0;
int drawEnd = lineHeight ~/ 2 + viewHeight ~/ 2;
if (drawEnd >= viewHeight) drawEnd = viewHeight - 1;
int texNum;
if (hitWallId >= 90) {
texNum = 98.clamp(0, textures.length - 1);
} else {
texNum = ((hitWallId - 1) * 2).clamp(0, textures.length - 2);
if (side == 1) texNum += 1;
}
int texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63);
if (side == 0 && math.cos(playerAngle) > 0) texX = 63 - texX;
if (side == 1 && math.sin(playerAngle) < 0) texX = 63 - texX;
double step = 64.0 / lineHeight;
double texPos = (drawStart - viewHeight / 2 + lineHeight / 2) * step;
Sprite texture = textures[texNum];
for (int y = drawStart; y < drawEnd; y++) {
int texY = texPos.toInt() & 63;
texPos += step;
// Calculate which Y pixel of the texture to sample
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
int color32 = ColorPalette.vga32Bit[colorByte];
int pixelColor = ColorPalette.vga32Bit[colorByte];
buffer.pixels[y * buffer.width + x] = color32;
// Darken Y-side walls for faux directional lighting
if (side == 1) {
pixelColor = _shadeColor(pixelColor);
}
_buffer.pixels[y * width + x] = pixelColor;
}
}
void _castSprites(
WolfEngine engine,
FrameBuffer buffer,
List<double> zBuffer,
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {
const int viewHeight = 160;
final Player player = engine.player;
final List<Entity> activeSprites = List.from(engine.entities);
for (
int y = math.max(0, drawStartY);
y < math.min(viewHeight, drawEndY);
y++
) {
double relativeY = (y - drawStartY) / spriteHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
// Sort entities from furthest to closest (Painter's Algorithm)
activeSprites.sort((a, b) {
double distA = player.position.distanceTo(a.position);
double distB = player.position.distanceTo(b.position);
return distB.compareTo(distA);
});
int colorByte = texture.pixels[texX * 64 + texY];
Coordinate2D dir = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
Coordinate2D plane =
Coordinate2D(-dir.y, dir.x) * math.tan((math.pi / 3) / 2);
for (Entity entity in activeSprites) {
Coordinate2D spritePos = entity.position - player.position;
double invDet = 1.0 / (plane.x * dir.y - dir.x * plane.y);
double transformX = invDet * (dir.y * spritePos.x - dir.x * spritePos.y);
double transformY =
invDet * (-plane.y * spritePos.x + plane.x * spritePos.y);
if (transformY > 0) {
int spriteScreenX = ((buffer.width / 2) * (1 + transformX / transformY))
.toInt();
int spriteHeight = (viewHeight / transformY).abs().toInt();
// In 1x1 buffer pixels, the width of the sprite is equal to its height
int spriteWidth = spriteHeight;
int drawStartY = -spriteHeight ~/ 2 + viewHeight ~/ 2;
if (drawStartY < 0) drawStartY = 0;
int drawEndY = spriteHeight ~/ 2 + viewHeight ~/ 2;
if (drawEndY >= buffer.height) drawEndY = viewHeight - 1;
int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
int clipStartX = math.max(0, drawStartX);
int clipEndX = math.min(buffer.width - 1, drawEndX);
int safeIndex = entity.spriteIndex.clamp(
0,
engine.data.sprites.length - 1,
);
Sprite spritePixels = engine.data.sprites[safeIndex];
for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
// Z-Buffer Check! Only draw the vertical stripe if it's in front of the wall
if (transformY < zBuffer[stripe]) {
int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(0, 63);
double step = 64.0 / spriteHeight;
double texPos =
(drawStartY - viewHeight / 2 + spriteHeight / 2) * step;
for (int y = drawStartY; y < drawEndY; y++) {
int texY = texPos.toInt() & 63;
texPos += step;
int colorByte = spritePixels.pixels[texX * 64 + texY];
if (colorByte != 255) {
// 255 is transparent
buffer.pixels[y * buffer.width + stripe] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
// 255 is the "transparent" color index in VGA Wolfenstein
if (colorByte != 255) {
_buffer.pixels[y * width + stripeX] = ColorPalette.vga32Bit[colorByte];
}
}
}
void _drawWeapon(WolfEngine engine, FrameBuffer buffer) {
const int viewHeight = 160;
@override
void drawWeapon(WolfEngine engine) {
int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex(
engine.data.sprites.length,
);
Sprite weaponSprite = engine.data.sprites[spriteIndex];
// Dropped the scale from 4 to 2 (a 50% reduction in size)
const int scale = 2;
const int weaponWidth = 64 * scale;
const int weaponHeight = 64 * scale;
int weaponWidth = (width * 0.5).toInt();
int weaponHeight = (viewHeight * 0.8).toInt();
int startX = (buffer.width ~/ 2) - (weaponWidth ~/ 2);
// Kept the grounding to the bottom of the screen
int startX = (width ~/ 2) - (weaponWidth ~/ 2);
int startY =
viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 2);
viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4);
for (int x = 0; x < 64; x++) {
for (int y = 0; y < 64; y++) {
int colorByte = weaponSprite.pixels[x * 64 + y];
for (int dy = 0; dy < weaponHeight; dy++) {
for (int dx = 0; dx < weaponWidth; dx++) {
int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63);
int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63);
int colorByte = weaponSprite.pixels[texX * 64 + texY];
if (colorByte != 255) {
int color32 = ColorPalette.vga32Bit[colorByte];
for (int sx = 0; sx < scale; sx++) {
for (int sy = 0; sy < scale; sy++) {
int drawX = startX + (x * scale) + sx;
int drawY = startY + (y * scale) + sy;
if (drawX >= 0 &&
drawX < buffer.width &&
drawY >= 0 &&
drawY < buffer.height) {
buffer.pixels[drawY * buffer.width + drawX] = color32;
}
}
int drawX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
void _drawDamageFlash(WolfEngine engine, FrameBuffer buffer) {
if (engine.damageFlashOpacity <= 0) return;
@override
void drawHud(WolfEngine engine) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
int alpha = (engine.damageFlashOpacity * 256).toInt().clamp(0, 256);
int invAlpha = 256 - alpha;
// 1. Draw Background
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160);
for (int i = 0; i < buffer.pixels.length; i++) {
int color = buffer.pixels[i];
// 2. Draw Stats (100% mathematically accurate right-aligned coordinates)
_drawNumber(1, 32, 176, engine.data.vgaImages); // Floor
_drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); // Score
_drawNumber(3, 120, 176, engine.data.vgaImages); // Lives
_drawNumber(
engine.player.health,
192,
176,
engine.data.vgaImages,
); // Health
_drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); // Ammo
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
int a = (color >> 24) & 0xFF;
// 3. Draw BJ's Face & Current Weapon
_drawFace(engine);
_drawWeaponIcon(engine);
}
// Blend with Red
r = ((r * invAlpha) + (255 * alpha)) >> 8;
g = (g * invAlpha) >> 8;
b = (b * invAlpha) >> 8;
@override
FrameBuffer finalizeFrame() {
// If the player took damage, overlay a red tint across the 3D view
if (_engine.player.damageFlash > 0) {
_applyDamageFlash();
}
return _buffer; // Return the fully painted pixel array
}
buffer.pixels[i] = (a << 24) | (b << 16) | (g << 8) | r;
// ===========================================================================
// PRIVATE HELPER METHODS
// ===========================================================================
/// Maps the planar VGA image data directly to 32-bit pixels.
/// (Assuming a 1:1 scale, which is standard for the 320x200 software renderer).
void _blitVgaImage(VgaImage image, int startX, int startY) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
for (int dy = 0; dy < image.height; dy++) {
for (int dx = 0; dx < image.width; dx++) {
int drawX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = dx.clamp(0, image.width - 1);
int srcY = dy.clamp(0, image.height - 1);
int plane = srcX % 4;
int sx = srcX ~/ 4;
int index = (plane * planeSize) + (srcY * planeWidth) + sx;
int colorByte = image.pixels[index];
if (colorByte != 255) {
_buffer.pixels[drawY * width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
}
}
}
@@ -433,26 +190,22 @@ class SoftwareRasterizer implements Rasterizer {
int value,
int rightAlignX,
int startY,
FrameBuffer buffer,
List<VgaImage> vgaImages,
) {
// The yellow 8x16 HUD digits start exactly at 96
const int zeroIndex = 96;
String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
int digit = int.parse(numStr[i]);
if (zeroIndex + digit < vgaImages.length) {
_blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY, buffer);
_blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY);
}
currentX += 8;
}
}
void _drawFace(WolfEngine engine, FrameBuffer buffer) {
void _drawFace(WolfEngine engine) {
int health = engine.player.health;
int faceIndex;
@@ -464,12 +217,11 @@ class SoftwareRasterizer implements Rasterizer {
}
if (faceIndex < engine.data.vgaImages.length) {
// Exactly X=136. This will perfectly smother the background face.
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164, buffer);
_blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164);
}
}
void _drawWeaponIcon(WolfEngine engine, FrameBuffer buffer) {
void _drawWeaponIcon(WolfEngine engine) {
int weaponIndex = 89; // Default to Pistol
if (engine.player.hasChainGun) {
@@ -479,84 +231,41 @@ class SoftwareRasterizer implements Rasterizer {
}
if (weaponIndex < engine.data.vgaImages.length) {
// Exactly X=256
_blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164, buffer);
_blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164);
}
}
void _drawHud(WolfEngine engine, FrameBuffer buffer) {
int statusBarIndex = engine.data.vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
// 1. Draw Background
_blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160, buffer);
// 2. Draw Stats (100% mathematically accurate right-aligned coordinates)
_drawNumber(1, 32, 176, buffer, engine.data.vgaImages); // Floor
_drawNumber(
engine.player.score,
96,
176,
buffer,
engine.data.vgaImages,
); // Score
_drawNumber(3, 120, 176, buffer, engine.data.vgaImages); // Lives
_drawNumber(
engine.player.health,
192,
176,
buffer,
engine.data.vgaImages,
); // Health
_drawNumber(
engine.player.ammo,
232,
176,
buffer,
engine.data.vgaImages,
); // Ammo
// 3. Draw BJ's Face & Current Weapon
_drawFace(engine, buffer);
_drawWeaponIcon(engine, buffer);
/// Darkens a 32-bit 0xAABBGGRR color by roughly 30% without touching Alpha
int _shadeColor(int color) {
int r = (color & 0xFF) * 7 ~/ 10;
int g = ((color >> 8) & 0xFF) * 7 ~/ 10;
int b = ((color >> 16) & 0xFF) * 7 ~/ 10;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
void _blitVgaImage(
VgaImage image,
int startX,
int startY,
FrameBuffer buffer,
) {
// Wolfenstein 3D VGA images are stored in "Mode Y" Planar format.
// We must de-interleave the 4 planes to draw them correctly!
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
/// Tints the top 80% of the screen red based on player.damageFlash intensity
void _applyDamageFlash() {
// Grab the intensity (0.0 to 1.0)
double intensity = _engine.player.damageFlash;
for (int y = 0; y < image.height; y++) {
for (int x = 0; x < image.width; x++) {
int drawX = startX + x;
int drawY = startY + y;
// Calculate how much to boost red and drop green/blue
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
if (drawX >= 0 &&
drawX < buffer.width &&
drawY >= 0 &&
drawY < buffer.height) {
// Planar to Linear coordinate conversion
int plane = x % 4;
int sx = x ~/ 4;
int index = (plane * planeSize) + (y * planeWidth) + sx;
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
int index = y * width + x;
int color = _buffer.pixels[index];
int colorByte = image.pixels[index];
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
if (colorByte != 255) {
// 255 is transparent
buffer.pixels[drawY * buffer.width + drawX] =
ColorPalette.vga32Bit[colorByte];
}
}
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt();
b = (b * colorDrop).toInt();
_buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r;
}
}
}