Kulor's Guide to Mode 7 Perspective Planes

Discussion of hardware and software development for Super NES and Super Famicom.

Moderator: Moderators

Forum rules
  • For making cartridges of your Super NES games, see Reproduction.
User avatar
kulor
Posts: 33
Joined: Thu Mar 15, 2018 12:49 pm

Kulor's Guide to Mode 7 Perspective Planes

Post by kulor »

A while back, I wanted to mess around with mode 7 perspective plane effects. Fortunately, Nova had put together this wonderful Mode 7 Previewer which lets you manipulate the mode 7 registers with javascript.
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 7.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 )
The screen is always 256x224 pixels for our purposes (sorry PAL fans).
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
Image

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
Image

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
Image

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
This info was taken from fullsnes. Using just this, you can get some pretty easy rotation in Nova's previewer:

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];
Copypaste this script into the box in Nova's previewer and check the "Do animation" box to see it rotate like so:

Figure 1.4
Image

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
Image

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
Image

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)
Thus, our normalized height is exactly 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
Image


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];
We now have the basic info we need to start piecing this effect together.

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
Thus, we can establish that, in order to turn a distance of 224 into a matrix scale parameter of 256, we need to multiply the distance by (8/7). If you're familiar with the SNES, you're probably thinking "hey, that's the screen aspect ratio!" However, as far as I can tell, it's a coincidence and has nothing to do with the aspect ratio; it's entirely based on how we defined coordinates in our game world, the camera's FOV, and how those correspond to the screen.
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
Image

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
Image

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
Image

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|
All that remains is to plug this into our script, and perform interpolation between the distances of these two angles:

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];
This snippet assumes a camera at position XYZ = (don't care, normalized height = 112 * sqrt(3), don't care), with variable 1 controlling the camera's pitch, and variable 2 controlling the m7y register to allow us to unstretch things a bit -- foreshadowing for the next section? With a pitch of 45, and a variable 2 of 190 (which is an arbitrary value that I eyeballed to make it look right), we get the following result:

Figure 3.4
Image

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
Image

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
Image

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
Image

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
Now we need to measure out how many texels are currently being traversed when m7y is 0, and when m7y is 223. Remember how the texel traversal was the same in Figure 4.1 and Figure 4.2? From that, we can conclude that the distances of the camera's bottom and top sides are the same as the span of texels traversed when m7y is set to the topmost and bottom-most lines.

Snippet 4.2

Code: Select all

td = dist(ta) = ~749.519
bd = dist(ba) = ~200.833
Take the offset between those two values, take your ideal i and offset it by the texels traversed when m7y is 0, take the latter as a percentage of the prior, and multiply that by the total number of scanlines.

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
Wrapping it all up, here's how we calculate it in a function, implemented in our script:

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];
variable 1 controls the camera pitch, variable 2 controls the camera height as 112 * sqrt(3) + variable 2 * 5, and variable 3 controls the camera yaw. A green square is drawn on the line where m7y is being set to. You will notice, when adjusting the pitch, the map appears to shift around; we don't care about that yet, this script iteration only demonstrates the correct per-line scale and the correct texture mapping for any given pitch and height combination. You will also notice that negative heights cause distortion; this is because I'm not accounting for negative height, and any negative height values are invalid. Any combination of pitch, yaw, and positive height will otherwise be correct, aside from the shifting.

Figure 4.4
Image

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
Image


Figure 4.6
Image

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? Roll! How about getting it to not shift around a bunch? We're using m7y to perform our texture mapping, but, as mentioned, m7y defines a point around which rotation and scaling pivots. Getting it to not shift around involves what I call recentering, which basically requires us to use the ofs registers to correct for how much it's off. This happens in two phases, for which I will assume a camera position of (0, normalized height, 0): the first phase involves moving the map's origin (0, 0, 0) point to the m7y line. The second phase involves measuring the amount by which the view is off from where it should be, breaking this offset into horizontal and vertical components, and applying those components to the ofs registers.
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
Image

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
Image

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
Image

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
Image

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
Image

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;
		}
	}
}
In order to use this centering offset, we need to break it into vertical and horizontal components, which is done with some simple sin/cos magic:

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;
...and then apply it to the ofs registers accordingly.

Figure 5.6
Image

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.

6. THE MYSTERY CURVE

We now have what appears to be a camera which correctly represents pitch, yaw, and position. We're done, right? Well, no, as it turns out. You see, in order to validate all my work thus far, I've been comparing these results to a 3D editor. What we have so far is very, very close, but it's still off by some amount. Unfortunately, I cannot tell you what exactly this amount we're off by is. What I know about it is that it can be described as a curve related to the camera's pitch, and needs to be added to the centering offset we just calculated. It has a simple relationship to height, which is to say at the normalized height, the curve's value is added; at 2 * normalized height, 2 * curve is added; and at normalized height / 2, curve / 2 is added. I eyeballed the rough offset at many different pitches and found a best fit curve for the data points, where x is pitch - 90, and y is the offset:

Figure 6.1
Image


Snippet 6.1

Code: Select all

y = -677x^5/56548800000 + 9521x^3/565488000 + 14851x/157080
But unfortunately, it lent no insight to what the meaning of this curve is. Is my calculation in Snippet 5.2 wrong? Does it have something to do with correcting some kind of fisheye effect? I don't know, and if you do know, you should reach out! But, once we add the mystery curve, we now have this iteration of the script, which is very close to accurate for any given pitch, yaw, and camera position:

Snippet 6.2

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 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;
		}
	}
}

var getMagicOffset = function() {
	//TODO: why????
	var a = cam.pitch - 90;
	var curve = (-677 * Math.pow(a, 5)) / 56548800000 + (9521 * Math.pow(a, 3)) / 565488000 + (14851 * a) / 157080;
	return curve * (cam.y / normalizedHeight);
}

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 = getCamCenter() + getMagicOffset();
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];
7. 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 7.1
Image

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 7.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}
}
The projection matrix, on the other hand, is generated in very much the same way as a regular 3D engine might:

Snippet 7.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}
}
The idea of a near clip plane and a far clip plane doesn't really apply outside of here, so these values were chosen arbitrarily.
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 7.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 getCamCenter = function(cam, topa, btma, negedge, upsidedown, lineoffs) {
	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;
		}
	}
}

var getMysteryCurve = function(cam) {
	//TODO: why????
	var a = cam.pitch - 90;
	var curve = (-677 * Math.pow(a, 5)) / 56548800000 + (9521 * Math.pow(a, 3)) / 565488000 + (14851 * a) / 157080;
	return curve * (cam.y / normalizedHeight);
}

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 = getCamCenter(cam, topa, btma, negedge, upsidedown, lineoffs) + getMysteryCurve(cam);
	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];
Figure 7.2
Image

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!

8. 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 8.1
Image
Last edited by kulor on Thu Aug 04, 2022 8:41 am, edited 6 times in total.
jeffythedragonslayer
Posts: 208
Joined: Thu Dec 09, 2021 12:29 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by jeffythedragonslayer »

Here are some thoughts for now...

I prefer talking about the "north" and 'east" sides of the map to "top" and "right" sides because those are unambiguous and always refer to the same geographic directions no matter where the camera is pointing.

Why can't roll be achieved in Mode 7?

The gyroscope has multiple arrows of each color, and the arrows that are on the great circles are not what is being referred to for the cardinal directions, that was confusing.

I think the shape of the camera's field of view is a frustrum or pyramid, not a triangle. I think section 3 could be more clear by referring to sides of the frustum, not sides of the camera.

Is there any particular reason Figure 5.3 and 5.5 has angles of 68 not 60?
Kulor wrote:Thus, we can establish that, in order to turn a distance of 224 into a matrix scale parameter of 256, we need to multiply the distance by (8/7). If you're familiar with the SNES, you're probably thinking "hey, that's the screen aspect ratio!" However, as far as I can tell, it's a coincidence and has nothing to do with the aspect ratio; it's entirely based on how we defined coordinates in our game world, the camera's FOV, and how those correspond to the screen.
I think it's a coincidence in the sense that 2^8=256 is a number that happens to come up a lot on 8 and 16 bit machines, which happens to be both the horizontal resolution and the matrix scale parameter.
Last edited by jeffythedragonslayer on Mon Aug 01, 2022 8:41 am, edited 1 time in total.
Fiskbit
Posts: 491
Joined: Sat Nov 18, 2017 9:15 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by Fiskbit »

FYI, I created this thread for discussion of broken image links. Please keep such posts there to avoid distracting from this awesome thread.

Really cool work here!
jeffythedragonslayer
Posts: 208
Joined: Thu Dec 09, 2021 12:29 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by jeffythedragonslayer »

The mystery curve looks a little bit like a negative tangent. When I programmed a raycasting engine I had to do fisheye correction like this tutorial explains:

https://permadi.com/1996/05/ray-casting-tutorial-8/

Which involves a cosine.

Do you think that the (m7x, m7y) center point will eventually go "infinitely" far away while pitching the camera up and up and up? If so I think the mystery curve may be a negative tangent as sine and cosine cannot produce infinity. Tangent does produce infinity discontinuities; and I'd expect discontinuities in the center point's position if you were also rendering a mode 7 sky as well as ground and pitching the camera up and up like that, because eventually the top scanline will be ground and the bottom scanline will be sky, which is the opposite of what you usually see. But you have data at absolute values of x that are greater than 90 degrees, and that is what is un-tangent like about that curve.
ehaliewicz
Posts: 18
Joined: Thu Oct 10, 2013 3:30 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by ehaliewicz »

jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pm Why can't roll be achieved in Mode 7?
Mode 7 is a series of parameters that allow you to describe how to map a horizontal scanline, into a straight, 2D line across texture space.
Without roll, any horizontal scanline can be correctly mapped to a 2D line across texture space, with no distortion, as a horizontal scanline won't change depth.

Once you introduce roll, it no longer becomes possible to correctly map a horizontal scanline with just a straight line, because as you traverse that horizontal scanline, the depth of position along the scanline changes. For example, if you tilt the camera left, a horizontal line going from the left to the right has an increase in depth. This causes perspective distortion, because texels that are further away should be mapped smaller, but mode 7 only knows how to make constant u or v coord changes through texture space, it can only trace perfectly straight lines.
User avatar
TmEE
Posts: 853
Joined: Wed Feb 13, 2008 9:10 am
Location: Estonia, Rapla city (50 and 60Hz compatible :P)
Contact:

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by TmEE »

This was a good read, thänk you very much for doing this ~
UnDisbeliever
Posts: 94
Joined: Mon Mar 02, 2015 1:11 am
Location: Australia (PAL)
Contact:

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by UnDisbeliever »

Very informative and useful. Thank-you for taking the time to write this guide.
TrekkiesUnite118
Posts: 83
Joined: Fri Mar 08, 2013 5:56 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by TrekkiesUnite118 »

I'll have to give it a read later, it could be interesting for Saturn VDP2 stuff.
User avatar
kulor
Posts: 33
Joined: Thu Mar 15, 2018 12:49 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by kulor »

jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pm I prefer talking about the "north" and 'east" sides of the map to "top" and "right" sides because those are unambiguous and always refer to the same geographic directions no matter where the camera is pointing.
First, thanks for all the feedback! I agree with this point, but I'll have to come back to this one because I'll have to redraw at least one of the images to make this change.
jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pm Why can't roll be achieved in Mode 7?
Erik explained this really well, but to elaborate a bit, it helps to imagine what a roll would even look like:
Image
This is what a camera with a pitch and yaw of 0 and a roll of 90 might look like. You can see the distance of the plane at the center of the screen is very (infinitely) far away, and the distance of the plane at the right edge of the screen is much closer. What we would have to do to render that is change the scale per-column, rather than per-scanline.
Unfortunately, the SNES only offers facilities for changing the mode 7 parameters up to once per-scanline, so we're unable to do that.
RGME's video really does a great job explaining the facilities that are available for accomplishing this effect on the SNES, I'd especially suggest watching it as a prerequisite to reading my guide.
jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pm The gyroscope has multiple arrows of each color, and the arrows that are on the great circles are not what is being referred to for the cardinal directions, that was confusing.
Yeah, that's a screenshot of the widget in Unity, I also noticed at the time that the colors seemed off. Unfortunately I'm not really sure I have a way to adjust this, aside from trying to draw my own widget...which I doubt would look as nice and might just make it more confusing. If anyone can offer an alternative I'd be happy to swap that image out.
Actually, looking at it again, I think it makes as much sense as it could. Look at, for example, the red arrow, and how that relates to the red circle, then look at the green arrow and how that relates to the green circle; the circles "orbit" the arrows in the direction the arrows are pointed.
jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pm I think the shape of the camera's field of view is a frustrum or pyramid, not a triangle. I think section 3 could be more clear by referring to sides of the frustum, not sides of the camera.
Basically, normally we would think of a camera as a viewing frustum, but in this case, because you only have per-scanline control of the parameters, you don't actually end up needing the left and right sides of the frustum, only the top and bottom...so the frustum can be shrunk down to a 2D triangle instead, which is easier to visualize. I thought about adding a blurb about this, but in order to explain it, I would have to either assume you already know about viewing frustums, or explain the concept myself as a prerequisite, which I felt would be a massive tangent for something where we ultimately aren't using two of the sides anyway. I also thought it would've been more confusing, as I would've then had to have specified that I'm always talking about vertical field of view and not horizontal field of view, and all the visualizations of the camera triangle would have had to have had more labels so it was more clear which side of the frustum you were looking at, and so on. I'm also not using the near or far clip planes (outside of sprite transforms, which do in fact work off a viewing frustum), so that would've been another thing to try to explain. It seemed much simpler to just throw out any assumptions about a viewing frustum and just simplify the camera to a triangle with 3 defined sides instead.
jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pm Is there any particular reason Figure 5.8 has angles of 68 not 60?
There is no figure 5.8, so I'm not sure what you're talking about.
iNCEPTIONAL
Posts: 891
Joined: Sun Jan 30, 2022 4:33 am

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by iNCEPTIONAL »

I have little idea what you are talking about--it does absolutely remind me why SNES programming simply is not for me--but I love it!

Apologies that this is all I can really contribute, but I just wanted say thank you. :D
jeffythedragonslayer
Posts: 208
Joined: Thu Dec 09, 2021 12:29 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by jeffythedragonslayer »

The roll of 90 is a good way to explain why roll doesn't work - guess you gotta go to Starfox for that. I fixed the figure number.
turboxray
Posts: 283
Joined: Thu Oct 31, 2019 12:56 am

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by turboxray »

My comment got moved to the images thread. Anyways, amazing write up!
User avatar
kulor
Posts: 33
Joined: Thu Mar 15, 2018 12:49 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by kulor »

jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pm Is there any particular reason Figure 5.3 and 5.5 has angles of 68 not 60?
Oops! Both of those numbers are supposed to be "60", it's a 0 with a cross through it. My handwriting is just really bad, especially with a mouse. I'll touch that one up when I get a moment.
jeffythedragonslayer wrote: Sun Jul 31, 2022 11:13 pmDo you think that the (m7x, m7y) center point will eventually go "infinitely" far away while pitching the camera up and up and up? If so I think the mystery curve may be a negative tangent as sine and cosine cannot produce infinity. Tangent does produce infinity discontinuities; and I'd expect discontinuities in the center point's position if you were also rendering a mode 7 sky as well as ground and pitching the camera up and up like that, because eventually the top scanline will be ground and the bottom scanline will be sky, which is the opposite of what you usually see. But you have data at absolute values of x that are greater than 90 degrees, and that is what is un-tangent like about that curve.
I also wanted to touch up on rendering a sky a bit. If you get rid of the scale check that creates the horizon in snippet 7.3:

Code: Select all

if (sl >= 0 || true) {
You can see that it seems to be trying to render a sky. However, if you click the "do animation" box to see it move around, you'll see that the sky isn't moving in the same direction as the ground. It also shows how my various calculations actually break down once the pitch starts pointing too far up, which isn't normally a problem because if you're just rendering a ground plane, then the ground will be completely off-screen well before the pitch starts testing the limits of these calculations.
What you would want to do is calculate a sky plane as a separate plane, thought of as a camera above and a plane beneath, but with some pitch amount greater than 90, and a yaw of 180. You'd do everything the exact same way, then, at the point where the per-line scale values for that sky plane become negative, you would switch over to using your ground plane instead.
As for the mystery curve being a tangent, I was unable to find a tangent that tracked the mystery curve accurately -- it's those tiny little positive/negative humps between y=-60 and y=60 that make it tricky. You're welcome to give it a shot: https://www.desmos.com/calculator/egllrmar3n
User avatar
kulor
Posts: 33
Joined: Thu Mar 15, 2018 12:49 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by kulor »

kulor wrote: Mon Aug 01, 2022 9:35 am What you would want to do is calculate a sky plane as a separate plane, thought of as a camera above and a plane beneath, but with some pitch amount greater than 90, and a yaw of 180. You'd do everything the exact same way, then, at the point where the per-line scale values for that sky plane become negative, you would switch over to using your ground plane instead.
Figured I would give this a shot, here's the script:

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 getCamCenter = function(cam, topa, btma, negedge, upsidedown, lineoffs) {
	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;
		}
	}
}

var getMagicOffset = function(cam) {
	//TODO: why????
	var a = cam.pitch - 90;
	var curve = (-677 * Math.pow(a, 5)) / 56548800000 + (9521 * Math.pow(a, 3)) / 565488000 + (14851 * a) / 157080;
	return curve * (cam.y / normalizedHeight);
}

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) {
	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(cam, topa, btma, m7yneg);
	//rectangle(125, lineoffs-3, 6, 6, "green", true)
	topa = Math.abs(topa)
	var topdist = dist(cam, topa);
	var btmdist = dist(cam, btma);
	var sl = lerp(1/topdist, 1/btmdist, scanline / 223);
	var scale = (1/sl) * distanceToScale;
	var camcenter = getCamCenter(cam, topa, btma, negedge, upsidedown, lineoffs) + getMagicOffset(cam);
	var voffscentercomp = Math.cos(cam.yaw/180 * Math.PI) * camcenter;
	var hoffscentercomp = -Math.sin(cam.yaw/180 * Math.PI) * camcenter;

	var a = rad(cam.yaw);
	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,
}

var skyCam = {
	x: -groundCam.x,
	y: 2 * normalizedHeight - groundCam.y,
	z: groundCam.z,
	fov: groundCam.fov,
	pitch: var1 + 180,
	yaw: 180 - (360 - var3 * 5),
}

//Do sprite transforms
var anObject = {
	x: 350,
	y: 30,
	z: 20
}
var matmv = getModelViewMatrix(groundCam);
var matp = getProjectionMatrix(groundCam);
var intermediary = pointTimesMatrix([anObject.x, anObject.y, anObject.z, 1], matmv);
var transformed = pointTimesMatrix(intermediary, matp);
var normalized = normalizePoint(transformed);
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);
var sp = calcPlane(skyCam);

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 if (sp.sl >= 0) {
	m7a = sp.m7a;
	m7b = sp.m7b;
	m7c = sp.m7c;
	m7d = sp.m7d;
	m7x = sp.m7x;
	m7y = sp.m7y;
	m7hofs = sp.m7hofs;
	m7vofs = sp.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];
Image
lidnariq
Posts: 10932
Joined: Sun Apr 13, 2008 11:12 am
Location: Seattle

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by lidnariq »

That makes me so happy
Post Reply