Created
March 18, 2022 04:20
-
-
Save JoeStrout/33cae15171593222b9a4d69a1e847536 to your computer and use it in GitHub Desktop.
Test of a Wolfenstein3D-style rendering engine for Mini Micro. Use arrow keys to move forward/backward/turn; alt+left/right to strafe. You can move right through walls, so... try not to.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 3D-ish rendering of walls, decorationss, and entities, | |
// using stretched sprites. | |
import "qa" | |
import "mathUtil" | |
clear | |
display(2).mode = displayMode.pixel | |
debugDisp = display(2) | |
debugDisp.clear | |
gfx.fillRect 0, 0, 960, 320, color.silver // floor | |
gfx.fillRect 0, 320, 960, 320, color.gray // ceiling | |
// Handy constants | |
twoPi = pi * 2 | |
halfPi = pi / 2 | |
degToRad = pi / 180 | |
radToDeg = 180 / pi | |
// Coordinate system: | |
// We'll work mainly in 2D, with map points represented as [x,y]. | |
// (We'll call the vertical dimension Z, often omitted/ignored.) | |
// 1 unit is the width (and height) of 1 wall section. Where | |
// z matters at all, the floor is at 0 and the ceiling is at 1. | |
// Camera: defines the current view point and angle, as well | |
// as parameters like field of view (fov) and distance limit. | |
// There is only one camera; this is a global object. | |
camera = {} | |
// camera.pos: position of the camera. Note the z position; | |
// when this is < 0.5 the ceiling feels high, because the camera | |
// is less than half the way to the ceiling. When it is close | |
// to 1, then the ceiling feels low and cramped. | |
camera.pos = [9.46, 3.43, 0.6] | |
// the forward direction, in degrees (-180 to 180) and radians | |
camera.angle = 148 | |
camera.angleRad = camera.angle * degToRad | |
// half the horizontal field of view, in degrees (0 to 90) and radians | |
camera.halfFov = 22.5 | |
camera.halfFovRad = camera.halfFov * degToRad | |
camera.setAngle = function(degrees) | |
camera.angle = (degrees + 180) % 360 - 180 | |
if camera.angle < -180 then camera.angle = camera.angle + 360 | |
camera.angleRad = camera.angle * degToRad | |
end function | |
camera.turn = function(degreesCCW) | |
self.setAngle self.angle + degreesCCW | |
end function | |
camera.moveForward = function(dist) | |
self.pos[0] = self.pos[0] + cos(self.angleRad) * dist | |
self.pos[1] = self.pos[1] + sin(self.angleRad) * dist | |
end function | |
camera.moveRight = function(dist) | |
self.pos[0] = self.pos[0] + sin(self.angleRad) * dist | |
self.pos[1] = self.pos[1] - cos(self.angleRad) * dist | |
end function | |
// Calculate the angle (in radians) of the given point, | |
// relative to the camera's forward direction. | |
camera.relativeAngle = function(point) | |
ang = (atan(point[1] - self.pos[1], point[0] - self.pos[0]) - | |
self.angleRad + pi) % twoPi - pi | |
if ang < -pi then ang = ang + twoPi | |
return ang | |
end function | |
// Renderer: holds all the data needed for rendering the scene. | |
Renderer = {} | |
Renderer.depthBuf = [0] * 960 // (actually a 1/depth buffer) | |
Renderer.depthBufWall = [null] * 960 | |
// Make a Wall class. A wall is represented by two ordered | |
// points: left and right (when viewed from the visible side). | |
Wall = {} | |
Wall.p = null // [left, right] | |
Wall.image = file.loadImage("/sys/pics/textures/ToonBrickA.png") | |
Wall.sprite = null | |
Wall.make = function(left, right) | |
w = new Wall | |
w.p = [left, right] | |
w.sprite = new Sprite | |
w.sprite.image = w.image | |
if left[0] == right[0] then w.sprite.tint = "#CCCCCC" | |
return w | |
end function | |
// Calculate the angle of each endpoint, in radians (range -pi to pi), | |
// relative to the current viewpoint and angle. | |
// Store in self.angles, and the (shortest) angle span in self.angSpan. | |
Wall.calcAngles = function | |
self.angles = [camera.relativeAngle(self.p[0]), camera.relativeAngle(self.p[1])] | |
self.angSpan = self.angles[0] - self.angles[1] | |
end function | |
// Call this method when a wall extends beyond the left end of the screen. | |
// Give it a reference point somewhere on the screen (at refScreenX), and | |
// the corresponding point on the wall in world coordinates. This method | |
// will then set self.x0 and self.invD0 so that the wall looks correct at | |
// the edge of the screen (by extrapolating way beyond it as needed). | |
Wall.extrapolateOnLeft = function(refScreenX, refWallPt) | |
// First, calculate t (distance along wall from right to left) | |
// and inverse-distance of where the wall intersects left | |
// edge of screen, from the wall reference point. | |
angRad = camera.angleRad + camera.halfFovRad // angle at screen edge | |
screenEdgeWorld = [camera.pos[0] + cos(angRad)*10, | |
camera.pos[1] + sin(angRad)*10] // a world position at screen edge | |
t = mathUtil.lineIntersectProportion(refWallPt, self.p[0], | |
camera.pos, screenEdgeWorld) // t along wall (ref->0) at screen edge | |
posCut = mathUtil.lerp2d(refWallPt, self.p[0], t) // wall pos at screen edge | |
invDcut = 1 / mathUtil.distance(posCut, camera.pos) // invD at screen edge | |
// Now we know all about the point on the wall at the edge | |
// of the screen, extrapolate to find a proper x0 and invD0. | |
self.x0 = refScreenX - refScreenX * (1/t) | |
refInvD1 = 1 / mathUtil.distance(refWallPt, camera.pos) | |
self.invD0 = refInvD1 + (invDcut - refInvD1) * (1/t) | |
end function | |
// Call this method when a wall extends beyond the right end of the screen. | |
// Give it a reference point somewhere on the screen (at refScreenX), and | |
// the corresponding point on the wall in world coordinates. This method | |
// will then set self.x1 and self.invD1 so that the wall looks correct at | |
// the edge of the screen (by extrapolating way beyond it as needed). | |
Wall.extrapolateOnRight = function(refScreenX, refWallPt) | |
// First, calculate t (distance along wall from ref point to right) | |
// and inverse-distance of where the wall intersects right | |
// edge of screen. | |
angRad = camera.angleRad - camera.halfFovRad // angle at screen edge | |
screenEdgeWorld = [camera.pos[0] + cos(angRad)*10, | |
camera.pos[1] + sin(angRad)*10] // a world position at screen edge | |
t = mathUtil.lineIntersectProportion(refWallPt, self.p[1], | |
camera.pos, screenEdgeWorld) // t along wall (0->1) at screen edge | |
posCut = mathUtil.lerp2d(refWallPt, self.p[1], t) // wall pos at screen edge | |
invDcut = 1 / mathUtil.distance(posCut, camera.pos) // invD of wall | |
// Now we know all about the point on the wall at the edge | |
// of the screen, extrapolate to find a proper x1 and invD1. | |
self.x1 = refScreenX + (960 - refScreenX) * (1/t) | |
refInvD1 = 1 / mathUtil.distance(refWallPt, camera.pos) | |
self.invD1 = refInvD1 + (invDcut - refInvD1) * (1/t) | |
end function | |
Wall.writeToDepthBuffer = function | |
// Assumes that calcAngles has already been called. | |
if self.angles[1] > camera.halfFovRad or self.angles[0] < -camera.halfFovRad then return // (out of view) | |
// Find the start and end screen column. | |
self.x0 = 480 - tan(self.angles[0])*1158 // (1158 ~= 480 / tan(halfFovRad)) | |
self.x1 = 480 - tan(self.angles[1])*1158 | |
cutOnLeft = self.angles[0] > camera.halfFovRad | |
cutOnRight = self.angles[1] < -camera.halfFovRad | |
if cutOnLeft and cutOnRight then | |
// This wall is cut off on both sides. Dang, what a pain. | |
// Let's find a point in the wall at the middle of the screen. | |
screenMidWorld = [camera.pos[0] + cos(camera.angleRad)*10, | |
camera.pos[1] + sin(camera.angleRad)*10] | |
t = mathUtil.lineIntersectProportion(self.p[0], self.p[1], | |
camera.pos, screenMidWorld) // t along wall (0->1) at screen midpoint | |
posMid = mathUtil.lerp2d(self.p[0], self.p[1], t) // wall pos at screen mid | |
// OK, now we know where the wall is in the center of the screen. | |
// Let's use this, and the intersection of each screen edge, | |
// to compute where the off-screen wall ends should be. | |
self.extrapolateOnLeft 480, posMid | |
self.extrapolateOnRight 480, posMid | |
else if cutOnLeft then | |
// This wall is cut off on the left. Let's compute exactly | |
// where on the wall that screen intersection happens, and | |
// deal with just the visible part. | |
self.invD1 = 1 / mathUtil.distance(self.p[1], camera.pos) | |
self.extrapolateOnLeft self.x1, self.p[1] | |
else if cutOnRight then | |
self.invD0 = 1 / mathUtil.distance(self.p[0], camera.pos) | |
self.extrapolateOnRight self.x0, self.p[0] | |
else | |
// Easy case: wall is entirely on screen. | |
self.invD0 = 1 / mathUtil.distance(self.p[0], camera.pos) | |
self.invD1 = 1 / mathUtil.distance(self.p[1], camera.pos) | |
end if | |
self.x0 = round(self.x0) | |
self.x1 = round(self.x1) | |
self.invDmid = (self.invD0 + self.invD1) / 2 | |
invDstep = (self.invD1 - self.invD0) / (self.x1 - self.x0) | |
invD = self.invD0 | |
for x in range(self.x0, self.x1) | |
if x >= 0 and x < 960 then | |
if Renderer.depthBuf[x] < invD then | |
Renderer.depthBuf[x] = invD | |
Renderer.depthBuf[x] = invD | |
Renderer.depthBufWall[x] = self | |
end if | |
end if | |
// to-do: skip ahead to X=0, rather than stepping there like this | |
invD = invD + invDstep | |
end for | |
end function | |
Wall.positionSprite = function | |
sp = self.sprite | |
sp.x = (self.x0 + self.x1)/2 | |
sp.y = 320 | |
h0 = 300 * self.invD0 | |
h1 = 300 * self.invD1 | |
sp.setCorners [[self.x0, sp.y-h0], [self.x1, sp.y-h1], | |
[self.x1, sp.y+h1], [self.x0, sp.y+h0]] | |
end function | |
// Make some helper methods to generate sets of walls. | |
makeLongWall = function(leftmost, rightmost) | |
result = [] | |
if leftmost[0] == rightmost[0] then | |
if leftmost[1] < rightmost[1] then | |
for y in range(leftmost[1], rightmost[1]-1) | |
result.push Wall.make([leftmost[0], y], [leftmost[0], y+1]) | |
end for | |
else | |
for y in range(rightmost[1], leftmost[1]-1) | |
result.push Wall.make([leftmost[0], y+1], [leftmost[0], y]) | |
end for | |
end if | |
else if leftmost[1] == rightmost[1] then | |
if leftmost[0] < rightmost[0] then | |
for x in range(leftmost[0], rightmost[0]-1) | |
result.push Wall.make([x, leftmost[1]], [x+1, leftmost[1]]) | |
end for | |
else | |
for x in range(rightmost[0], leftmost[0]-1) | |
result.push Wall.make([x+1, leftmost[1]], [x, leftmost[1]]) | |
end for | |
end if | |
else | |
qa.fail "walls must differ in only one dimension" | |
end if | |
return result | |
end function | |
// Make a box with the walls facing inward (e.g., for | |
// the outer walls of the map) | |
makeInwardBox = function(left, bottom, width, height) | |
top = bottom + height | |
right = left + width | |
return makeLongWall([left,bottom], [left,top]) + | |
makeLongWall([left,top], [right,top]) + | |
makeLongWall([right,top], [right,bottom]) + | |
makeLongWall([right,bottom], [left,bottom]) | |
end function | |
// Make a box with the walls facing outward (a column or obstacle). | |
makeOutwardBox = function(left, bottom, width, height) | |
top = bottom + height | |
right = left + width | |
return makeLongWall([left,top], [left,bottom]) + | |
makeLongWall([right,top], [left,top]) + | |
makeLongWall([right,bottom], [right,top]) + | |
makeLongWall([left,bottom], [right,bottom]) | |
end function | |
// First let's define the map. | |
walls = [] | |
walls = makeInwardBox(0, 0, 10, 10) | |
walls = walls + makeOutwardBox(2, 3, 3, 2) | |
walls = walls + makeOutwardBox(7, 6, 2, 2) | |
walls = walls + makeOutwardBox(5, 8, 1, 1) | |
Renderer.analyze = function | |
self.depthBuf = [0]*960 | |
self.depthBufWall = [null]*960 | |
// Find the walls within the viewing angle, | |
// AND potentially visible at all. | |
// Write these to the depth buffer. | |
maxAng = camera.halfFovRad | |
minAng = -maxAng | |
for w in walls | |
w.visible = false | |
w.calcAngles | |
if w.angSpan <= 0 then continue // backside | |
if w.angSpan > pi then continue // behind us | |
if w.angles[0] < minAng or w.angles[1] > maxAng then continue // out of view | |
w.writeToDepthBuffer | |
end for | |
// Then, prepare the list of visible walls, sorted by depth. | |
self.visibleWalls = [] | |
lastWall = null | |
for x in Renderer.depthBuf.indexes | |
if not Renderer.depthBufWall[x] or Renderer.depthBuf[x] == 0 then continue | |
if Renderer.depthBufWall[x] == lastWall then continue | |
lastWall = Renderer.depthBufWall[x] | |
if not lastWall.visible then | |
lastWall.visible = true | |
self.visibleWalls.push lastWall | |
end if | |
end for | |
self.visibleWalls.sort "invDmid" | |
end function | |
Renderer.renderWithLines = function | |
for x in Renderer.depthBuf.indexes | |
if not Renderer.depthBufWall[x] or Renderer.depthBuf[x] == 0 then continue | |
h = 300 * Renderer.depthBuf[x] | |
gfx.line x, 150+h, x, 150-h, Renderer.depthBufWall[x].color | |
end for | |
end function | |
Renderer.render = function | |
// Assume that Renderer.analyze has already been called. | |
// So all we have to do is display the sprites for visible walls. | |
// (ToDo: and decorations, and entities.) | |
display(4).sprites = [] | |
for wall in self.visibleWalls | |
wall.positionSprite | |
display(4).sprites.push wall.sprite | |
end for | |
end function | |
rerender = function | |
debugDisp.clear | |
Renderer.analyze | |
Renderer.render | |
end function | |
rerender | |
while true | |
yield | |
needRender = true | |
alt = key.pressed("left alt") or key.pressed("right alt") | |
if key.pressed("escape") then | |
break | |
else if key.pressed("left") then | |
if alt then camera.moveRight -0.1 else camera.turn 2 | |
else if key.pressed("right") then | |
if alt then camera.moveRight 0.1 else camera.turn -2 | |
else if key.pressed("up") then | |
camera.moveForward 0.1 | |
else if key.pressed("down") then | |
camera.moveForward -0.1 | |
else | |
needRender = false | |
end if | |
if needRender then rerender | |
end while | |
key.clear |
Author
JoeStrout
commented
Mar 18, 2022
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment