So, it was a simple matter of figuring out what exactly I needed to do to get that plane effect, right?
After a cursory search, it was fairly easy to find guides on how to do this effect, but what I quickly discovered was that most of the guides talked about how to do it using a custom renderer of some sort, assuming you had pixel plotting capabilities. The closest I was able to find was a guide for how to accomplish the effect on the GBA, but I found it difficult to follow and was unable to port it to Nova's previewer, a difficulty which others apparently shared. I wasn't about to try to dive into the GBA hardware to figure out exactly what's seemingly making it different from the SNES, and I was a bit surprised that we -- the keepers of the "mode 7" gate, the lords of the F-Zero and Super Mario Kart realms -- didn't seem to have a guide on how to do the most popular mode 7 effect, the perspective plane, using actual mode 7 on the SNES.
So, that's what this is, a basic rundown of my understanding of (one of a handful of ways) how this effect works. The purpose of this guide isn't to provide a solution in 65816 assembly for the SNES (admittedly I haven't got that far with it yet!), but rather, to provide a model for an "ideal" implementation of this effect, by manipulating the SNES mode 7 registers using javascript in Nova's mode 7 previewer, which can then be adapted, simplified, and optimized to fit your purposes. A quick disclaimer, I am not some kind of hardcore math understander by any means. I never took trigonometry and I had to look up how to do matrix multiplication for this. So, if my terminology is incorrect, please let me know! I also admit that I don't actually have a complete understanding of some of this stuff, so if I say I don't know why something works, but you totally know why, also please let me know!
If you're here, you know what this effect is, so I'm not going to lay down an explanation of the basics. If you need such a thing, I would suggest watching the excellent Retro Game Mechanics Explained video on the topic. I'm going to assume you have a fundamental understanding of what we're doing here, and a high-level idea of what we're able to do in mode 7 to get this effect (changing scale and rotation per-line).
If you just want to see the finished version of the script, see Snippet 6.3!
1. THE GAME WORLD AND USING THE MATRIX PARAMETER REGISTERS
There's a few fundamental things we need to lay down in order to have the information we need to get started. First, what exactly are we trying to render? There's a 3D game world, consisting of a flat plane, a perspective camera with an adjustable position in X, Y, and Z, and an adjustable pitch and yaw, and a field of view; any number of objects which each have adjustable X, Y, and Z positions, and that's basically it. We'll be rendering the flat plane of this game world using the various mode 7 hardware registers, which consist of m7a, m7b, m7c, m7d, m7hofs, m7vofs, m7x, and m7y. I won't go into too much detail on how exactly to write these registers, as that info can be found in fullsnes, but what we need to know is the A, B, C, and D parameters are the elements of a 2x2 matrix which defines the affine transformation that is applied to the mode 7 background layer, m7hofs and m7vofs specify a texel offset for the layer, and m7x and m7y define a point in screen space around which scaling and rotation pivots.
Snippet 1.1
Code: Select all
Formula for Rotation/Enlargement/Reduction in Matrix Form:
( VRAM.X ) = ( M7A M7B ) * ( SCREEN.X+M7HOFS-M7X ) + ( M7X )
( VRAM.Y ) ( M7C M7D ) ( SCREEN.Y+M7VOFS-M7Y ) ( M7Y )
Second, we need to define how movement works in our game world. The finest amount you can move a mode 7 map using m7hofs and m7vofs (which I will now refer to collectively as the ofs registers) is one texel, which is one pixel on the mode 7 map regardless of how it's oriented, so it makes sense to define one coordinate unit in our game world as the equivalent of moving one texel across the mode 7 map. Here is an arbitrary view of a mode 7 plane, with a 3D cube with a width, length, and height of 1 texel, placed on top:
Figure 1.1
We'll be defining the topmost left point of the map plane as (0, 0, 0) in our game world. We also need to define how movement and rotation will work. With our camera, a pitch of 0 will represent an upright camera facing straight forward, pointing parallel to the plane; a pitch of 90 will represent the camera facing straight down, pointing perpendicular to the plane. A yaw of 0 will orient the map such that the top edge of the map is towards the top edge of the screen; a yaw of 90 will orient it such that the right edge of the map will be towards the top edge of the screen. Moving the camera along the X axis positively will move the camera towards the right edge of the map, and moving the camera along the Z axis positively will move the camera towards the top edge of the map; moving the camera along the Y axis positively will change the camera's height such that it's more distant from the plane.
Figure 1.2
A camera positioned arbitrarily over a (repeating) mode 7 plane. Red line represents the left edge of the map, blue line represents the top edge of the map. Red arrow represents positive X movement, blue arrow represents positive Z movement, green arrow represents positive Y movement. Red circle/arrow represents positive pitch, green circle/arrow represents positive yaw, and blue circle represents roll, which we are not able to convey with the constraints of mode 7, so it will not be used.
So, looking at Nova's previewer, this is our default view:
Figure 1.3
In the script, we can see all the registers are set to 0, except for m7a and m7d, which are set to 256. To make sense of this, we need to understand some basic things about how these mode 7 registers can be used. There's more to this, but the matrix parameters can basically be thought of as the following:
Snippet 1.2
Code: Select all
m7a = cos(angle) * xscale
m7b = sin(angle) * xscale
m7c = -sin(angle) * yscale
m7d = cos(angle) * yscale
Snippet 1.3
Code: Select all
var smallcount = framecount / Math.PI / 100;
var scale = 256;
var angle = smallcount;
m7a = Math.cos(angle) * scale;
m7b = Math.sin(angle) * scale;
m7c = -Math.sin(angle) * scale;
m7d = Math.cos(angle) * scale;
m7x = 128;
m7y = 112;
m7hofs = 0;
m7vofs = 0;
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
Figure 1.4
m7x and m7y have been set to the point in the middle of the screen so that the rotation isn't centered around a corner -- try setting them to 0 if you're not sure what I mean. From this, we can see that a scale of 256 and an angle of 0 gives us the map without any rotation and at a scale of 1x, as though it's any other tile layer on the SNES; this is just how the matrix parameter hardware registers work, and why m7a and m7d were set to 256 by default. If you're having trouble following any of this, I'd encourage you to play around with various scale and angle values in the script in Snippet 1.3 -- remember that angle is in radians (range of 0 to τ) and not degrees (range 0 to 360) -- but the basic takeaway from all this should be: the A, B, C, and D matrix parameters are how we set the scale and angle of the mode 7 plane, and a scale of 256 is a scale factor of 1.
2. THE CAMERA TRIANGLE
If we look again at the default view in Figure 1.3, we can think of this as already rendering a plane using a perspective camera. In order to get this view in the game world we've defined, the camera would need to be positioned somewhere above the plane (positive Y position), with a pitch of 90 degrees and a yaw of 0 degrees. What position would this camera need to be at? This value is a very important one, which I will refer to as the normalized height. Calculating this height will allow us to establish a relationship between Y position and field of view, and a matrix scale parameter. First, what is the field of view? In SNES mode 7, because the only means of control we have over the various parameters are per-scanline changes, we can think of our perspective camera as a triangle, with the visible span of the plane across the screen vertically representing one side (which I will refer to as the plane side), the top edge of the screen representing another side (which I will refer to as the top side), and the bottom edge of the screen representing the third side (which I will refer to as the bottom side). Here we can see a camera positioned above a plane, rendering an arbitrary view (blue box, top and left of the screen labeled), with the green line representing the camera's height above the plane, and the purple triangle representing these three aforementioned sides:
Figure 2.1
The field of view (FOV), then, is the angle between the top side and the bottom side. We can define this arbitrarily; for my purposes I chose an FOV of 60 degrees.
Figure 2.2
Because our default view is a scale factor of 1, we know that the vertical span of the portion of map being rendered is the same as the height of the screen, which is 224. So, we now know the length of the plane side of the camera triangle, and the angle of the triangle. If we split the triangle down the middle along the FOV, we get two nice right triangles and can easily solve for the height using SOHCAHTOA:
Snippet 2.1
Code: Select all
tand(30) = x/112
x = 112 * sqrt(3)
There's one more simple thing we need to do. By default, our camera should have an X and Z position of 0, but the default view does not represent this; if we had a camera that was facing down directly over the point we defined as (0, 0, 0) on our map, then that point in our map should appear in the center of the screen, not in the top-left corner. In order to get this, we need to subtract a base H offset of 128 and a base V offset of 112 to the ofs registers. This gives us the view accurately representing a camera at position (0, 112 * sqrt(3), 0) with a pitch of 90:
Figure 2.3
Snippet 2.2
Code: Select all
var baseXOffset = 128;
var baseYOffset = 112;
m7a = 0x0100;
m7b = 0x0000;
m7c = 0x0000;
m7d = 0x0100;
m7x = 0;
m7y = 0;
m7hofs = -baseXOffset;
m7vofs = -baseYOffset;
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
3. SCALE
"In order to do a mode 7 plane, you adjust the scale and rotation every scanline." How much do we adjust the scale by, though? The per-line scale values needed can be thought of as the interpolated values between the camera's distance from the plane at the point of plane intersection with the camera's top side, and the camera's distance from the plane at the point of plane intersection with the camera's bottom side; simply put, the length of the top side and the bottom side. If we refer to the default view in Figure 2.3, we can intuit that, because the camera's pitch is 90, the top side and the bottom side must have the same distance, and, because our view is a scale factor of 1, this distance must represent a matrix scale parameter of 256. Now that we know our normalized height, we can once again use SOHCAHTOA to solve for these distances:
Snippet 3.1
Code: Select all
cosd(30) = (112 * sqrt(3))/x
x = 224
If we want to adjust the pitch, however, we need different scale values for every scanline. Here's where an important thing we need to keep in mind comes into play: the relationship between the scale parameter and distance is 1/distance, as described here. That is to say, if we simply subtract some constant value from scale every scanline, we don't end up with a plane; what we actually need is, for every scanline, to subtract some constant value from 1/scale, and set the scale to the reciprocal of that value. Using this tidbit, it's actually pretty easy to get a fairly convincing, albeit non-perspective-accurate plane effect.
Snippet 3.2
Code: Select all
if (scanline > 112) {
var a = framecount / 200;
var s = (scanline - 112) * 2;
var d = 1 / s;
var h = (Math.sin((framecount / 150) % (Math.PI * 2)) + 1) * 90 + 10;
var lam = d * h * 2;
m7a = toFixed(Math.cos(a) * lam);
m7b = toFixed(Math.sin(a) * lam);
m7c = toFixed(-Math.sin(a) * lam);
m7d = toFixed(Math.cos(a) * lam);
m7hofs = toFixed(Math.sin((framecount / 125) % (Math.PI * 2)));
m7vofs = toFixed(Math.cos((framecount / 125) % (Math.PI * 2)));
m7x = m7hofs + 128;
m7y = m7vofs + 224 + 112;
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
}
else {
m7a = 0;
m7b = 0;
m7c = 0;
m7d = 0;
m7x = 128;
m7y = 112;
m7hofs = 0;
m7vofs = 0;
}
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
Figure 3.1
The above snippet simply offsets the scanline number (which is 0 at the top of the screen and 223 at the bottom of the screen), and sets the distance to the reciprocal of that value, with an additional multiplication to fake an adjustable height. I'm not going to elaborate on this because how this works has no bearing on the perspective-accurate model we're attempting to describe, but this is simply to demonstrate how the 1/distance relationship to scale allows us to render a plane. If all you need is a camera with a pitch of 0 and you don't care about having any other objects, you can probably just use this!
In our model, we're going to use this information to interpolate between the distance of the camera's top side and bottom side to get our per-line scale. Linear interpolation is all we need to use for this, and a simple implementation of it is:
Snippet 3.3
Code: Select all
var lerp = function(v0, v1, t) {
return v0 + t * (v1 - v0);
};
var sl = lerp(1/var1, 1/var2, scanline / 223);
var scale = 1/sl;
m7a = scale * (8/7);
m7b = 0x0000;
m7c = 0x0000;
m7d = scale * (8/7);
m7x = 128;
m7y = 0;
m7hofs = 0;
m7vofs = 0;
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
Figure 3.2
In the above snippet, variable 1 and variable 2 can be used to directly control the top side distance and bottom side distance respectively. For example, a variable 1 of 300 and a variable 2 of 50 are illustrated in Figure 3.2. Any positive value for these two distances is valid. It's quite apparent that the mode 7 map is stretched and doesn't look correct, I.E. the texture mapping is incorrect, but we'll get to that in a moment, all we care about right now is the scale.
The next step is to apply this distance interpolation to our camera. Let's suppose we have a camera at the normalized height with a pitch of 45:
Figure 3.3
You can see that there is a right triangle formed by the camera's height above the plane, a span of the plane itself, and the bottom side of the camera. There is also a right triangle formed by the camera's height above the plane, a span of the plane itself, and the top side of the camera. The angles of these triangles at the edge touching the camera can be calculated:
Snippet 3.4
Code: Select all
hfov = fov / 2
da = 90 - pitch
topa = |da + hfov|
btma = |da - hfov|
Snippet 3.5
Code: Select all
var normalizedHeight = 112 * Math.sqrt(3);
var distanceToScale = 8/7;
var lerp = function(v0, v1, t) {
return v0 + t * (v1 - v0);
};
var rad = function(d) {
return d * Math.PI / 180;
};
var dist = function(a) {
return normalizedHeight / Math.cos(rad(a));
}
var fov = 60;
var hfov = fov / 2;
var da = 90 - var1;
var topa = Math.abs(da + hfov);
var btma = Math.abs(da - hfov);
var topdist = dist(topa);
var btmdist = dist(btma);
var sl = lerp(1/topdist, 1/btmdist, scanline / 223);
var scale = (1/sl) * distanceToScale;
m7b = 0x0000;
m7c = 0x0000;
m7x = 128;
m7hofs = 0;
m7vofs = 0;
if (sl >= 0) {
m7a = scale;
m7d = scale;
m7y = var2;
}
else {
m7a = 0;
m7d = 0;
m7y = 112;
}
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
Figure 3.4
On the left is the result of our code snippet, on the right is a reference in a 3D engine. As you can see, the per-line scale is now correct.
There are a few more things to unpack in Snippet 3.5 before we can move on. We can think of solving for the third side of a triangle, with one side defined as the camera's height from the plane, and the second side defined as an arbitrary span of the plane, as solving for the distance of the plane from the camera along that side. This is our dist function. As an aside, we need to convert our degree angles into radians to work in javascript's math library, as it's lacking sind, cosd and tand, which is the purpose of our rad function. The last thing is, we can account for the horizon by checking to make sure the resulting scale value is positive -- this puts the horizon line infinitely far away.
4. TEXTURE MAPPING
But everything is stretched and looks wrong! Well, at this point, there are multiple solutions to this problem. My observation was that adjusting the m7y parameter seemed to allow for vertical texture scaling. This works counter-intuitively to the intended purpose of m7y, which is to define a pivot for scaling and rotation, and nothing to do with texel traversal, but using the register this way was ideal because it meant I wouldn't need to calculate another per-line value -- m7y could be set once for the whole frame and the texture mapping would be done. But what's the correct value to set m7y to?
That, my friend, was a very difficult problem to come up with an answer to.
The solution came when I observed that, if m7y is set to 0 (the topmost scanline), the vertical texel traversal across the screen was the same as if the per-line scale had always been the value it was for the bottom-most scanline.
Figure 4.1
On the left, per-line scale is the interpolated value between the distances of the two camera sides, with m7y set to 0. On the right, per-line scale is always the distance of the camera's bottom side. Notice the top and bottom edges of the screen line up on both. Similarly, if m7y is set to 223 (the bottom-most scanline), the vertical texel traversal across the screen was the same as if the per-line scale had always been the value it was for the topmost scanline.
Figure 4.2
On the left, per-line scale is the interpolated value between the distances of the two camera sides, with m7y set to 223. On the right, per-line scale is always the distance of the camera's top side. Again, notice the top and bottom edges of the screen line up on both. The above figures show a camera with a pitch of 45 degrees, but this is actually true for any pitch. Given this, you can figure out the number of texels you want to traverse for the correct texture mapping, then you can determine which line you need to put m7y on in order to have it traverse that many texels. Well, because we just so happened to make game world coordinates the size of texels, the number of texels we want to traverse is actually equal to the span of the plane between the camera's bottom side and the camera's top side.
Figure 4.3
One way you could solve for this span would be as follows:
Snippet 4.1
Code: Select all
tand(75) = x/(112 * sqrt(3)), x = 112 * (3 + 2 sqrt(3))
tand(15) = x/(112 * sqrt(3)), x = 112 * (2 sqrt(3) - 3)
(112 * (3 + 2 sqrt(3))) - (112 * (2 sqrt(3) - 3)) = i = 672 texels
Snippet 4.2
Code: Select all
td = dist(ta) = ~749.519
bd = dist(ba) = ~200.833
Snippet 4.3
Code: Select all
td - bd = 749.519 - 200.833 = ~548.686
i - td = 672 - 200.833 = ~471.167
471.167 / 548.686 = ~85.872%
223 scanlines * 85.872% = ~191.494 = m7y
Snippet 4.4
Code: Select all
var baseXOffset = 128;
var baseYOffset = 112;
var normalizedHeight = 112 * Math.sqrt(3);
var distanceToScale = 8/7;
var lerp = function(v0, v1, t) {
return v0 + t * (v1 - v0);
};
var rad = function(d) {
return d * Math.PI / 180;
};
var dist = function(a) {
return cam.y / Math.cos(rad(a));
}
var texelSpan = function(a) {
return Math.tan(rad(a)) * cam.y;
}
var getm7y = function() {
var i, ta, ba, td, bd;
ta = topa;
ba = btma;
td = dist(ta);
bd = dist(ba);
if (m7yneg) {
i = texelSpan(ta) + texelSpan(ba);
}
else {
i = texelSpan(ta) - texelSpan(ba);
}
var dd = td - bd;
var ib = i - bd;
if (dd == 0) {
return 112;
}
else {
return ib / dd * 223;
}
}
var cam = {
x: 0,
y: normalizedHeight + var2 * 5,
z: 0,
fov: 60,
pitch: var1,
yaw: 360 - var3 * 5,
}
var da = 90 - cam.pitch;
var hfov = cam.fov / 2;
var topa = da + hfov;
var btma = da - hfov;
var m7yneg = false;
if (btma < 0) {
m7yneg = true;
}
btma = Math.abs(btma)
var lineoffs = getm7y();
rectangle(125, lineoffs-3, 6, 6, "green", true)
topa = Math.abs(topa)
var topdist = dist(topa);
var btmdist = dist(btma);
var sl = lerp(1/topdist, 1/btmdist, scanline / 223);
var scale = (1/sl) * distanceToScale;
if (sl >= 0) {
var a = rad(cam.yaw);
m7a = Math.cos(a) * scale;
m7b = Math.sin(a) * scale;
m7c = -Math.sin(a) * scale;
m7d = Math.cos(a) * scale;
m7hofs = -baseXOffset + cam.x;
m7vofs = -baseYOffset - cam.z;
m7x = 128 + m7hofs;
m7y = m7vofs + lineoffs;
}
else {
m7a = 0x0000;
m7b = 0x0000;
m7c = 0x0000;
m7d = 0x0000;
m7x = 128;
m7y = 112;
m7hofs = 0;
m7vofs = 0;
}
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
Figure 4.4
Our texture mapping is now correct. There are a few things to unpack before we can move on. We've introduced yaw, which is a simple matter of taking the camera's yaw, converting it to radians, and then plugging it into our matrix parameters as seen in Snippet 1.3. If the camera's bottom side angle is negative relative to the camera's Y intersect line, there is a slight difference in how the m7y value is calculated, due to the bd value representing a negative span already. This is the purpose of the m7yneg flag.
Figure 4.5
Figure 4.6
It's also worth mentioning that the line we're setting m7y to is the line where the scale factor is 1. This may present an alternative for how to calculate this value, or allow for some easy optimization.
I'll also mention here that we're now accounting for the camera's X and Z position as well. There's nothing special to this, it can simply be added to the ofs registers. One last thing that does merit mentioning though, the final values for both m7x and m7y need to be added to their corresponding ofs registers. If you don't do this, you get distortion. I picked up this tidbit from someone in the snesdev Discord server, and I'm not really certain why it's the case -- I'm sure it's staring me right in the face in Snippet 1.1 or something. If you're the one who dropped me this line, give me a shout and I'll edit your name in here! Also, if you have an explanation for this, I'm all ears...!
5. RECENTERING
Now the camera has pitch, height, yaw, position...what more could you ask for?
The first phase is actually fairly simple, because the origin point is always only off by some m7vofs amount. This can be seen by forcing the per-line scale to a factor of 1 and comparing various yaws:
Figure 5.1
This is the m7y offset for a pitch of 60, drawn over a map with a forced per-line scale factor of 1. On the left is a yaw of 0, on the right is some arbitrary non-zero yaw; as you can see, the distance from the origin is the same in both, and it's always off only on the texel vertical (m7vofs) axis. Now, we previously noted that the m7y line is the line with a scale factor of 1. It's also worth noting that Snippet 4.4 will produce a result of line 112 at a pitch of 90 degrees. When we're dealing with a scale factor of 1 and no rotation, the offset is simply the span of pixels on the screen; in this case, the span of pixels from where the m7y line is to where the center of the screen is when the camera yaw is 0. So, we can simply offset the current pitch's m7y line by the m7y line at a pitch of 90 degrees (which, again, is 112), to get the texel span that is our m7vofs offset needed for bringing the map's origin to the m7y line.
Snippet 5.1
Code: Select all
var lineoffs = getm7y();
m7vofs = -baseYOffset + (112 - lineoffs) - cam.z;
Figure 5.2
You would be forgiven for thinking that we're basically done, as this is starting to look pretty much correct, but we've got a bit more to do. At our FOV of 60, if we set the pitch to 60, there's an interesting situation to observe:
Figure 5.3
The camera's bottom side lines up perfectly with the height. What that means is, if we were to adjust the height of the camera with a pitch of 60 degrees, we would expect the bottom of the screen to remain centered on the same point, in this case the origin of the map. Currently, this is clearly not what's happening.
Figure 5.4
So how much is it off by exactly? Well, we know that we want the origin of the map to be where the camera's Y intersect is, which in this case coincides with the camera's bottom side. We also know that the origin of the map is currently on the m7y line, because we just put it there. We need to measure the span of texels from the camera's Y intersect to where the m7y line casts to the plane.
Figure 5.5
This is done by taking the m7y line, converting it (back) to a percentage of the vertical span of the screen, multiplying it by the camera's FOV to convert it into an angle, then adding it to either the camera's top side or bottom side depending on the pitch, and measuring the texel span of that angle.
Snippet 5.2
Code: Select all
var da = 90 - cam.pitch;
var hfov = cam.fov / 2;
var topa = da + hfov;
var btma = da - hfov;
var negedge = false;
var m7yneg = false;
var upsidedown = cam.pitch > 90;
if (upsidedown) {
if (topa < 0) {
negedge = true;
}
}
else {
if (btma < 0) {
negedge = true;
}
}
if (btma < 0) {
m7yneg = true; //needed for getm7y()
}
btma = Math.abs(btma)
var lineoffs = getm7y();
topa = Math.abs(topa)
var getCamCenter = function() {
if (upsidedown) {
var los = (223 - lineoffs) / 223;
var d = cam.fov - los * cam.fov;
if (negedge) {
//top side is behind the cam Y intersect
return -Math.tan(rad(topa + d)) * cam.y;
}
else {
//top side is in front of the cam Y intersect
return Math.tan(rad(topa - d)) * cam.y;
}
}
else {
var los = lineoffs / 223;
var d = cam.fov - los * cam.fov;
if (negedge) {
//bottom side is behind the cam Y intersect
return -Math.tan(rad(btma - d)) * cam.y;
}
else {
//bottom side is in front of the cam Y intersect
return Math.tan(rad(btma + d)) * cam.y;
}
}
}
Snippet 5.3
Code: Select all
var camcenter = getCamCenter();
var voffscentercomp = Math.cos(cam.yaw/180 * Math.PI) * camcenter;
var hoffscentercomp = -Math.sin(cam.yaw/180 * Math.PI) * camcenter;
[...]
m7hofs = -baseXOffset + hoffscentercomp + cam.x;
m7vofs = -baseYOffset + (112 - lineoffs) - voffscentercomp - cam.z;
Figure 5.6
The origin point now stays comfortably at the bottom of the screen as height is adjusted. There is a small bit of jitter, which is exacerbated as the camera moves lower, but that is an unavoidable side-effect of the finest unit of adjustment being one texel.
EDIT: There's an error in this previous calculation! The correct way to calculate the camera center is actually much simpler:
Snippet 5.4
Code: Select all
var camcenter = (-Math.tan(((cam.pitch - 90) * Math.PI) / 360) * normalizedHeight) * (cam.y / normalizedHeight);
Snippet 5.5
Code: Select all
var baseXOffset = 128;
var baseYOffset = 112;
var normalizedHeight = 112 * Math.sqrt(3);
var distanceToScale = 8/7;
var lerp = function(v0, v1, t) {
return v0 + t * (v1 - v0);
};
var rad = function(d) {
return d * Math.PI / 180;
};
var dist = function(a) {
return cam.y / Math.cos(rad(a));
}
var texelSpan = function(a) {
return Math.tan(rad(a)) * cam.y;
}
var getm7y = function() {
var i, ta, ba, td, bd;
ta = topa;
ba = btma;
td = dist(ta);
bd = dist(ba);
if (m7yneg) {
i = texelSpan(ta) + texelSpan(ba);
}
else {
i = texelSpan(ta) - texelSpan(ba);
}
var dd = td - bd;
var ib = i - bd;
if (dd == 0) {
return 112;
}
else {
return ib / dd * 223;
}
}
var cam = {
x: Math.sin(((framecount / 10) % 256) / 256 * 2 * Math.PI) * 512,
y: normalizedHeight + var2 * 5,
z: Math.cos(((framecount / 10) % 256) / 256 * 2 * Math.PI) * 512,
fov: 60,
pitch: var1,
yaw: 360 - var3 * 5,
}
var da = 90 - cam.pitch;
var hfov = cam.fov / 2;
var topa = da + hfov;
var btma = da - hfov;
var negedge = false;
var m7yneg = false;
var upsidedown = cam.pitch > 90;
if (upsidedown) {
if (topa < 0) {
negedge = true;
}
}
else {
if (btma < 0) {
negedge = true;
}
}
if (btma < 0) {
m7yneg = true;
}
btma = Math.abs(btma)
var lineoffs = getm7y();
//rectangle(125, lineoffs-3, 6, 6, "green", true)
topa = Math.abs(topa)
var topdist = dist(topa);
var btmdist = dist(btma);
var sl = lerp(1/topdist, 1/btmdist, scanline / 223);
var scale = (1/sl) * distanceToScale;
var camcenter = (-Math.tan(((cam.pitch - 90) * Math.PI) / 360) * normalizedHeight) * (cam.y / normalizedHeight);
var voffscentercomp = Math.cos(cam.yaw/180 * Math.PI) * camcenter;
var hoffscentercomp = -Math.sin(cam.yaw/180 * Math.PI) * camcenter;
if (sl >= 0) {
var a = rad(cam.yaw);
m7a = Math.cos(a) * scale;
m7b = Math.sin(a) * scale;
m7c = -Math.sin(a) * scale;
m7d = Math.cos(a) * scale;
m7hofs = -baseXOffset + hoffscentercomp + cam.x;
m7vofs = -baseYOffset + (112 - lineoffs) - voffscentercomp - cam.z;
m7x = 128 + m7hofs;
m7y = m7vofs + lineoffs;
}
else {
m7a = 0x0000;
m7b = 0x0000;
m7c = 0x0000;
m7d = 0x0000;
m7x = 128;
m7y = 112;
m7hofs = 0;
m7vofs = 0;
}
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
6. SPRITE TRANSFORMS
Why did we go through all this effort to make an effect that appears perspective-accurate instead of just using our fake Snippet 3.2 version and calling it good enough? Well, there's two reasons. One, we can now think of a mode 7 plane in terms of a camera object in a 3D space. Two, we can now have other objects track alongside our perspective plane accurately. I'm going to keep this section short because it's all identical to how this is done in any regular 3D engine, and there are countless articles describing the exact details of the theory much more thoroughly than I could possibly hope to, because my understanding of this stuff is admittedly very limited.
Basically, each object should be thought of as having an X, Y, Z position. We then need to turn this 3D point in our game world into screen coordinates. Once we have screen coordinates, we can E.G. put a hardware sprite there. The basic concept will involve using a few different matrices:
Figure 6.1
Multiplying the object's position (X, Y, Z, 1) by the world-to-camera matrix results in an intermediary 4x1 matrix; multiplying that by the projection matrix results in the screen position. But how do we get these matrices? Well, because we're unable to convey roll, our world-to-camera matrix looks slightly different than it might usually look in a 3D engine:
Snippet 6.1
Code: Select all
p = camera pitch
w = camera yaw
x, y, z = camera position
{
{cos(w), sin(p) * sin(w), -sin(w) * cos(p), 0},
{0, cos(p), sin(p), 0},
{-sin(w), sin(p) * cos(w), -cos(p) * cos(w), 0}
{x * -cos(w) + z * sin(w), x * sin(p) * -sin(w) + y * -cos(p) + z * sin(p) * -cos(w), x * sin(w) * cos(p) + y * -sin(p) + z * cos(p) * cos(w), 1}
}
Snippet 6.2
Code: Select all
f = far clip plane, 10000
n = near clip plane, 0.3
fov = 60
a = screen aspect ratio, 8/7
{
{(1 / tan((fov / 2) * (π / 180))) / a, 0, 0, 0},
{0, 1 / tan((fov / 2) * (π / 180)), 0, 0},
{0, 0, -(f + n) / (f - n), -1},
{0, 0, -2 * n * f / (f - n), 0}
}
Once we have our resulting point, one more step remains, which is to normalize the point. This is done by taking the new X, Y, and Z values, and dividing each of them by the new W. The resulting X and Y will range from -1 to 1, because I'm copping this stuff from OpenGL tutorials, and will need to be scaled up to match our screen coordinate range of (0 to 255, 0 to 223). The resulting Z coordinate can be used to clip the object, which is necessary as the object may end up drawing to the screen when it shouldn't be otherwise; the Z coordinate can also be used to determine how to scale the sprite, which is not something I have any guidance on at the moment.
Here is the finished version of our script, with all of these pieces in place, and drawing a green square where our object is. This has been modified a little to allow for easier calculations of multiple planes/sprites, and to make it more clear what's happening on a per-frame or per-scanline basis.
Snippet 6.3
Code: Select all
var baseXOffset = 128;
var baseYOffset = 112;
var normalizedHeight = 112 * Math.sqrt(3);
var distanceToScale = 8/7;
var lerp = function(v0, v1, t) {
return v0 + t * (v1 - v0);
};
var rad = function(d) {
return d * Math.PI / 180;
};
var dist = function(cam, a) {
return cam.y / Math.cos(rad(a));
}
var texelSpan = function(cam, a) {
return Math.tan(rad(a)) * cam.y;
}
var getm7y = function(cam, topa, btma, neg) {
var i, ta, ba, td, bd;
ta = topa;
ba = btma;
td = dist(cam, ta);
bd = dist(cam, ba);
if (neg) {
i = texelSpan(cam, ta) + texelSpan(cam, ba);
}
else {
i = texelSpan(cam, ta) - texelSpan(cam, ba);
}
var dd = td - bd;
var ib = i - bd;
if (dd == 0) {
return 112;
}
else {
return ib / dd * 223;
}
}
var getModelViewMatrix = function(cam) {
var p = cam.pitch;
var w = 360 - cam.yaw;
var x = cam.x;
var y = cam.y;
var z = cam.z;
return [
[Math.cos(rad(w)), Math.sin(rad(p)) * Math.sin(rad(w)), -Math.sin(rad(w)) * Math.cos(rad(p)), 0],
[0, Math.cos(rad(p)), Math.sin(rad(p)), 0],
[-Math.sin(rad(w)), Math.sin(rad(p)) * Math.cos(rad(w)), -Math.cos(rad(p)) * Math.cos(rad(w)), 0],
[
x * -Math.cos(rad(w)) + z * Math.sin(rad(w)),
x * Math.sin(rad(p)) * -Math.sin(rad(w)) + y * -Math.cos(rad(p)) + z * Math.sin(rad(p)) * -Math.cos(rad(w)),
x * Math.sin(rad(w)) * Math.cos(rad(p)) + y * -Math.sin(rad(p)) + z * Math.cos(rad(p)) * Math.cos(rad(w)),
1
]
];
}
var getProjectionMatrix = function(cam) {
//far and near clipping planes, hardcoding because they're not really used otherwise
var n = 0.3;
var f = 10000;
return [
[(1/Math.tan((cam.fov/2)*(Math.PI/180)))/(8/7), 0, 0, 0],
[0, 1/Math.tan((cam.fov/2)*(Math.PI/180)), 0, 0],
[0, 0, -(f + n) / (f - n), -1],
[0, 0, -2 * n * f / (f - n), 0]
];
}
var pointTimesMatrix = function(p, m) {
return [
m[0][0] * p[0] + m[1][0] * p[1] + m[2][0] * p[2] + m[3][0] * p[3],
m[0][1] * p[0] + m[1][1] * p[1] + m[2][1] * p[2] + m[3][1] * p[3],
m[0][2] * p[0] + m[1][2] * p[1] + m[2][2] * p[2] + m[3][2] * p[3],
m[0][3] * p[0] + m[1][3] * p[1] + m[2][3] * p[2] + m[3][3] * p[3]
];
}
var normalizePoint = function(p) {
return [p[0]/p[3], p[1]/p[3], p[2]/p[3], p[3]/p[3]];
}
var calcPlane = function(cam) {
//prep (per-frame)
var da = 90 - cam.pitch;
var hfov = cam.fov / 2;
var topa = da + hfov;
var btma = da - hfov;
var negedge = false;
var m7yneg = false;
var upsidedown = cam.pitch > 90;
if (upsidedown) {
if (topa < 0) {
negedge = true;
}
}
else {
if (btma < 0) {
negedge = true;
}
}
if (btma < 0) {
m7yneg = true;
}
btma = Math.abs(btma);
//centering offset (per-frame)
var lineoffs = getm7y(cam, topa, btma, m7yneg);
//rectangle(125, lineoffs-3, 6, 6, "green", true)
//texel recentering (per-frame)
topa = Math.abs(topa);
var camcenter = (-Math.tan(((cam.pitch - 90) * Math.PI) / 360) * normalizedHeight) * (cam.y / normalizedHeight);
var voffscentercomp = Math.cos(cam.yaw/180 * Math.PI) * camcenter;
var hoffscentercomp = -Math.sin(cam.yaw/180 * Math.PI) * camcenter;
//rotation (per-frame)
var a = rad(cam.yaw);
//scale part 1 (per-frame)
var topdist = dist(cam, topa);
var btmdist = dist(cam, btma);
//scale part 2 (per-scanline)
var sl = lerp(1/topdist, 1/btmdist, scanline / 223);
var scale = (1/sl) * distanceToScale;
var ret = {};
ret.sl = sl;
ret.m7a = Math.cos(a) * scale;
ret.m7b = Math.sin(a) * scale;
ret.m7c = -Math.sin(a) * scale;
ret.m7d = Math.cos(a) * scale;
ret.m7hofs = -baseXOffset + hoffscentercomp + cam.x;
ret.m7vofs = -baseYOffset + (112 - lineoffs) - voffscentercomp - cam.z;
ret.m7x = 128 + m7hofs;
ret.m7y = m7vofs + lineoffs;
return ret;
}
var groundCam = {
x: Math.sin(((framecount / 10) % 256) / 256 * 2 * Math.PI) * 512,
y: normalizedHeight + var2 * 5,
z: Math.cos(((framecount / 10) % 256) / 256 * 2 * Math.PI) * 512,
fov: 60,
pitch: var1,
yaw: 360 - var3 * 5,
}
//Do sprite transforms
var objs = [
{
x: 0, y: 0, z: 0
},
{
x: 50, y: Math.abs(Math.sin(framecount / 30)) * 50, z: 50
},
{
x: 100, y: Math.abs(Math.sin(framecount / 30)) * 100, z: 100
}
]
for (var e in objs) {
var cur = objs[e];
var matmv = getModelViewMatrix(groundCam);
var matp = getProjectionMatrix(groundCam);
var intermediary = pointTimesMatrix([cur.x, cur.y, cur.z, 1], matmv);
var transformed = pointTimesMatrix(intermediary, matp);
var normalized = normalizePoint(transformed);
cur.ny = 112 + normalized[1] * 112;
if (normalized[2] < 1) {
var sprxpos = 128 + normalized[0] * 128;
var sprypos = 112 + normalized[1] * 112;
rectangle(sprxpos - 3, 224 - sprypos - 3, 6, 6, "green", true);
}
}
//Do plane effect
var gp = calcPlane(groundCam);
if (gp.sl > 0) {
m7a = gp.m7a;
m7b = gp.m7b;
m7c = gp.m7c;
m7d = gp.m7d;
m7x = gp.m7x;
m7y = gp.m7y;
m7hofs = gp.m7hofs;
m7vofs = gp.m7vofs;
}
else {
m7a = 0x0000;
m7b = 0x0000;
m7c = 0x0000;
m7d = 0x0000;
m7x = 128;
m7y = 112;
m7hofs = 0;
m7vofs = 0;
}
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
I know this section is fairly short and could be packed to the brim with far more theory, but again, there are countless tutorials that describe that stuff far better than I could, as my understanding of this material is just not at the point where I could guide others on it. The basic takeaway is, if you generate the matrices as I outlined, and do the multiplication as I outlined (the order is important!), you will get the right results. This is the easy part compared to all the plane nonsense we just did!
7. CONCLUSION
This problem has been haunting me for months now, so it's very satisfying to finally have a working solution. I really hope this work is helpful to others, and I expect it's going to help me a lot in trying to come up with mode 7 effects for my projects. Hopefully you have all been liberated and are now able to freely fly above as many flat planes as your heart desires. Special shoutouts go to Ari, who personally dealt with me being hyperfocused on this problem for far too long and even losing a number of nights of sleep to it; ironchew, Jacob, Lillianna, Catador, lidnariq, Nova, undisbeliever, Runic, Erik, mrkotfw, GValiente, Louis G., the snesdev discord server, and any others who lent me a hand in trying to figure out this seemingly herculean problem. If you made it this far, thanks for sticking with me, and if you have any suggestions for what can be improved, do let me know!
All the scripts and snippets in this guide are released under the public domain. If you found this useful, I would appreciate a shout in your credits!
Figure 7.1