Kulor's Guide to Mode 7 Perspective Planes

Discussion of hardware and software development for Super NES and Super Famicom. See the SNESdev wiki for more information.

Moderator: Moderators

Forum rules
  • For making cartridges of your Super NES games, see Reproduction.
none
Posts: 117
Joined: Thu Sep 03, 2020 1:09 am

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by none »

rainwarrior wrote: Thu Aug 04, 2022 2:20 pm Is the actual range of inputs for all camera settings narrow enough to generically just be a table of reciprocals?
It seems to be, with some compromises, you need to weigh precision against view distance, and table size.

However now that I think more about the hardware division approach that one doesn't seem so bad, I think it should be possible to make it quite fast, maybe one can fit some of the computation into the wait cycles.

Also with the LUT approach, I don't remember exactly how indirect HDMA works, but wouldn't it be possible to do the table lookups with that?
93143
Posts: 1717
Joined: Fri Jul 04, 2014 9:31 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by 93143 »

none wrote: Thu Aug 04, 2022 2:50 pmI think it should be possible to make it quite fast, maybe one can fit some of the computation into the wait cycles.
If you're willing to write larger, more integrated chunks of code rather than just using a divider subroutine or macro that has no knowledge of the calling code, it should be possible to interleave other stuff with the divides.

As an example, the unsigned 16x16 multiplication routine I wrote a while back takes no penalty from waiting for the multiplier.

The divider may be more difficult to do this with, simply because it takes longer, but it's absolutely possible to save some time this way. Remember also that the instruction that reads the answer doesn't read it right away; the opcode and address fetches count toward the 16 wait cycles.
psycopathicteen
Posts: 3140
Joined: Wed May 19, 2010 6:12 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by psycopathicteen »

Here is a formula for Mode-7 using only A, C and BGY:

Code: Select all

X = A(SX + BGX - CX) + B(SY + BGY - CY) + CX
Y = C(SX + BGX - CX) + D(SY + BGY - CY) + CY

P = Cz/(cos(b)d - sin(b)(Sy - 112))

BGX = Cx - 128
BGY = P*(cos(b)(Sy - 112) + sin(b)d) - Sy + Cy

A = P*cos(a)
B = sin(a)
C = -P*sin(a)
D = cos(a)
none
Posts: 117
Joined: Thu Sep 03, 2020 1:09 am

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by none »

psycopathicteen wrote: Thu Aug 04, 2022 4:09 pm Here is a formula for Mode-7 using only A, C and BGY:

Code: Select all

X = A(SX + BGX - CX) + B(SY + BGY - CY) + CX
Y = C(SX + BGX - CX) + D(SY + BGY - CY) + CY

P = Cz/(cos(b)d - sin(b)(Sy - 112))

BGX = Cx - 128
BGY = P*(cos(b)(Sy - 112) + sin(b)d) - Sy + Cy

A = P*cos(a)
B = sin(a)
C = -P*sin(a)
D = cos(a)
This seems kind of cryptic, but I think I have made some sense out of this, correct me if I'm wrong

Basically I think the first two lines describe how mode 7 works inside the PPU internally. X and Y are the generated tilemap coordinates, A,B,C,D are the matrix registers, SX is always zero, SY is the scanline, BGX and BGY are the scroll registers and CX and CY are m7x and m7y.

The rest describes what the actual program looks like and it seems to nearly work, I tried to translate it to JS

Code: Select all

function cos(a) {return(Math.cos(a))}
function sin(a) {return(Math.sin(a))}

var yaw = var1  * Math.PI / 180;
var pitch = var2  * Math.PI / 180;

var camera_x = 0;
var camera_y = 0;
var camera_z = 5000;

var a = yaw;
var b = pitch;

var d = 128;


var Cx = camera_x;
var Cy = camera_y;
var Cz = camera_z;

var Sy = scanline;

var P = Cz/(cos(b) * d - sin(b) * (Sy - 112));

var A = P*cos(a);
var B = sin(a);
var C = -P*sin(a);
var D = cos(a);

var BGX = Cx - 128;
var BGY = P * (cos(b) * (Sy - 112) + sin(b) * d) - Sy + Cy;

m7a = A;
m7b = B;
m7c = C;
m7d = D;
m7x = 0;
m7y = 0;
m7hofs = BGX;
m7vofs = BGY;

return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
However there seems to be a problem that comes from a wrong interpretation of what the PPU actually does because it is missing the clipping for BGX - CX and the like.

To prove the point, if you paste this line into your browsers javascript console (not the script editor, the console you get when you click on inspect element) to override the clipping function, the JS above starts to work correctly.

Code: Select all

function clip(val) { return val }
Idk what is wrong here, NovaSquirrel seems to have copied it over from Mesen S correctly. Maybe I'm missing something? Do you have a source for that snippet or can you explain how it is supposed to work?

Edit: Nevermind, I've got it to work. The matrix parameters needed to be scaled up

Code: Select all

function cos(a) {return(Math.cos(a))}
function sin(a) {return(Math.sin(a))}

var yaw = var1  * Math.PI / 180;
var pitch = var2  * Math.PI / 180;

var camera_x = 0;
var camera_y = 0;
var camera_z = 150;

var a = yaw;
var b = pitch;

var d = 128;


var Cx = camera_x;
var Cy = camera_y;
var Cz = camera_z;

var Sy = scanline;

var P = Cz/(cos(b) * d - sin(b) * (Sy - 112));

var A = P*cos(a);
var B = sin(a);
var C = -P*sin(a);
var D = cos(a);

var BGX = Cx - 128;
var BGY = P * (cos(b) * (Sy - 112) + sin(b) * d) - Sy + Cy;

m7a = Math.floor(A * 63);
m7b = Math.floor(B * 63);
m7c = Math.floor(C * 63);
m7d = Math.floor(D * 63);
m7x = 0;
m7y = 0;
m7hofs = Math.floor(BGX);
m7vofs = Math.floor(BGY);

return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
Still unsure on how to move the camera around though. Cx and Cy don't seem to work
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 »

rainwarrior wrote: Thu Aug 04, 2022 11:44 am Your topdist/btmdist do seem to be dependent on these three things: cam.FOV, cam.pitch, cam.y

So, changing any of those 3 should necessitate a new table, I think? (This is definitely the set of things I would expect to affect the divide.)
Yeah, changes to any of those would require generating a new HDMA table, or table for per-scanline scale. But if we're just talking a lookup table for (1/value) * const, the only dependency there is FOV (which would otherwise be the const), so the whole thing could be baked into a lookup without limiting pitch or height control.
rainwarrior wrote: Thu Aug 04, 2022 11:44 am16 pixels is probably far too much to interpolate and have it look nice. Quake wasn't subdividing vertically, AFAIK. I think every second line will probably be fine (will report back after I try it), but I think much more than that would start to degrade quality quickly.
I've been curious about this for a while...so I tried it!
I hacked it in like so:

Code: Select all

	//scale part 2 (per-scanline)
	var sl = lerp(1/topdist, 1/btmdist, scanline / 223);
		var precision = 1073741824;
		var faketop = 1/topdist;
		faketop = Math.round(faketop * precision) / precision;
		var fakebtm = 1/btmdist;
		fakebtm = Math.round(fakebtm * precision) / precision;
		var fakelerp = Math.round(((fakebtm - faketop) / 223) * precision) / precision;
		sl = faketop + fakelerp * scanline;
	var scale = (1/sl) * distanceToScale;
The basic idea is, once you're working with reciprocals, you'd have to switch over to a different number base, 0.16 fixed point or 0.8 fixed point or something. The reciprocal lookups would have that conversion baked-in, and here precision is some value that brings the reciprocals up to 16-bit or 8-bit precision, or whatever you want. A precision of 1073741824 scales the reciprocals up to the signed 16-bit range, and a precision of 4194304 scales the reciprocals up to the signed 8-bit range. Well, it ends up looking like...
Image
(this should cycle through 4 views: the Unity reference, the regular lerp() version, the fakelerp version with 16-bit precision, and the fakelerp version with 8-bit precision)
[edit: I changed the code snippet out to more accurately reflect converting all the distances to fixed point, if you try this version it does look a bit worse than the image here, but it's still easily what I would consider acceptable at 8-bit and nearly indistinguishable at 16-bit]
Kinda a hard view to compare, but I picked it because at a really high altitude and pointing the camera up, it seemed to have a bigger error margin. I was pretty surprised though because even with just 8-bit precision, you can't really complain! Sweeping pitch values does look a bit stuttery, especially at really high altitudes, but it would certainly be usable, and with 16-bit precision it's almost indistinguishable. So I'm convinced you only really need the one division per-frame for this, no multiple lerps necessary.
psycopathicteen
Posts: 3140
Joined: Wed May 19, 2010 6:12 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by psycopathicteen »

none wrote: Thu Aug 04, 2022 5:53 pm
psycopathicteen wrote: Thu Aug 04, 2022 4:09 pm Here is a formula for Mode-7 using only A, C and BGY:

Code: Select all

X = A(SX + BGX - CX) + B(SY + BGY - CY) + CX
Y = C(SX + BGX - CX) + D(SY + BGY - CY) + CY

P = Cz/(cos(b)d - sin(b)(Sy - 112))

BGX = Cx - 128
BGY = P*(cos(b)(Sy - 112) + sin(b)d) - Sy + Cy

A = P*cos(a)
B = sin(a)
C = -P*sin(a)
D = cos(a)
This seems kind of cryptic, but I think I have made some sense out of this, correct me if I'm wrong

Basically I think the first two lines describe how mode 7 works inside the PPU internally. X and Y are the generated tilemap coordinates, A,B,C,D are the matrix registers, SX is always zero, SY is the scanline, BGX and BGY are the scroll registers and CX and CY are m7x and m7y.

The rest describes what the actual program looks like and it seems to nearly work, I tried to translate it to JS

Code: Select all

function cos(a) {return(Math.cos(a))}
function sin(a) {return(Math.sin(a))}

var yaw = var1  * Math.PI / 180;
var pitch = var2  * Math.PI / 180;

var camera_x = 0;
var camera_y = 0;
var camera_z = 5000;

var a = yaw;
var b = pitch;

var d = 128;


var Cx = camera_x;
var Cy = camera_y;
var Cz = camera_z;

var Sy = scanline;

var P = Cz/(cos(b) * d - sin(b) * (Sy - 112));

var A = P*cos(a);
var B = sin(a);
var C = -P*sin(a);
var D = cos(a);

var BGX = Cx - 128;
var BGY = P * (cos(b) * (Sy - 112) + sin(b) * d) - Sy + Cy;

m7a = A;
m7b = B;
m7c = C;
m7d = D;
m7x = 0;
m7y = 0;
m7hofs = BGX;
m7vofs = BGY;

return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
However there seems to be a problem that comes from a wrong interpretation of what the PPU actually does because it is missing the clipping for BGX - CX and the like.

To prove the point, if you paste this line into your browsers javascript console (not the script editor, the console you get when you click on inspect element) to override the clipping function, the JS above starts to work correctly.

Code: Select all

function clip(val) { return val }
Idk what is wrong here, NovaSquirrel seems to have copied it over from Mesen S correctly. Maybe I'm missing something? Do you have a source for that snippet or can you explain how it is supposed to work?

Edit: Nevermind, I've got it to work. The matrix parameters needed to be scaled up

Code: Select all

function cos(a) {return(Math.cos(a))}
function sin(a) {return(Math.sin(a))}

var yaw = var1  * Math.PI / 180;
var pitch = var2  * Math.PI / 180;

var camera_x = 0;
var camera_y = 0;
var camera_z = 150;

var a = yaw;
var b = pitch;

var d = 128;


var Cx = camera_x;
var Cy = camera_y;
var Cz = camera_z;

var Sy = scanline;

var P = Cz/(cos(b) * d - sin(b) * (Sy - 112));

var A = P*cos(a);
var B = sin(a);
var C = -P*sin(a);
var D = cos(a);

var BGX = Cx - 128;
var BGY = P * (cos(b) * (Sy - 112) + sin(b) * d) - Sy + Cy;

m7a = Math.floor(A * 63);
m7b = Math.floor(B * 63);
m7c = Math.floor(C * 63);
m7d = Math.floor(D * 63);
m7x = 0;
m7y = 0;
m7hofs = Math.floor(BGX);
m7vofs = Math.floor(BGY);

return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs];
Still unsure on how to move the camera around though. Cx and Cy don't seem to work
CX and CY are supposed to be both the camera and m7x and m7y.

Just so you know, I didn't test this out. Just did a ton of math problems until I got to this solution. Where do you download the thing to try it out?
93143
Posts: 1717
Joined: Fri Jul 04, 2014 9:31 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by 93143 »

none wrote: Thu Aug 04, 2022 1:28 pmperformance isn't much worse with division than with multiplication (13 against 5 wait cycles I think)
93143 wrote: Thu Aug 04, 2022 1:34 pmThe 8-cycle 8x8 unsigned multiplier and 16-cycle 16/8 unsigned divider
93143 wrote: Thu Aug 04, 2022 3:05 pmRemember also that the instruction that reads the answer doesn't read it right away; the opcode and address fetches count toward the 16 wait cycles.
...oh right, that's probably where that came from. If you use absolute addressing to read the answer, your numbers check out. With direct page, you've got to fill 14 and 6 cycles respectively...
none
Posts: 117
Joined: Thu Sep 03, 2020 1:09 am

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by none »

psycopathicteen wrote: Thu Aug 04, 2022 8:01 pm CX and CY are supposed to be both the camera and m7x and m7y.

Just so you know, I didn't test this out. Just did a ton of math problems until I got to this solution. Where do you download the thing to try it out?
Ah, ok. That makes sense.

It is a really nice solution.

You can copy and paste the JS into NovaSquirrels utility: https://novasquirrel.github.io/Mode7Preview/
It was mentioned in the OP.
User avatar
rainwarrior
Posts: 8732
Joined: Sun Jan 22, 2012 12:03 pm
Location: Canada
Contact:

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by rainwarrior »

psycopathicteen wrote: Thu Aug 04, 2022 4:09 pmHere is a formula for Mode-7 using only A, C and BGY
Ah. I see what you mean now. B/D set the rotation, and moving just the Y pivot can transform B/D's scale into the position along the centre axis that you need. That is definitely easier than trying to set HOFS+VOFS.

I think in the case of F-Zero and FF6, their technique of just reusing identical scales for A/C + B/D saves having to calculate a 3rd value at all, but they're compromised because their vertical scale is automatically determined by their horizontal scale.

For separate vertical and horizontal scales, I was planning to use B/D (like Pilotwings and Mario Kart appear to), but it would probably be faster if I could update only Y. I will definitely investigate this, thanks for clarifying with the formula.
psycopathicteen
Posts: 3140
Joined: Wed May 19, 2010 6:12 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by psycopathicteen »

I tried plugging it into Nova's mode 7 viewer, and for some reason the far away background is glitched.

The games that use the same tables for A/B and C/D, are they perspectively accurate or are they distorted? Are they more accurate to slope-based perspective (straight lines remain straight), or by angle-based perspective (straight lines get curved)?
User avatar
rainwarrior
Posts: 8732
Joined: Sun Jan 22, 2012 12:03 pm
Location: Canada
Contact:

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by rainwarrior »

psycopathicteen wrote: Sat Aug 06, 2022 9:14 amThe games that use the same tables for A/B and C/D, are they perspectively accurate or are they distorted? Are they more accurate to slope-based perspective (straight lines remain straight), or by angle-based perspective (straight lines get curved)?
If done right they are accurately a perspective, of the slope-based kind you describe. They just can't do a perspective with arbitrary width and depth, because it makes the depth dependent on the width.

Though... now that I think about it, using A/B + C/D takes 2 HDMA channels. Using A + C + Y would take 3? I guess it's a tradeoff of computation time vs. HDMA channels.
none
Posts: 117
Joined: Thu Sep 03, 2020 1:09 am

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by none »

rainwarrior wrote: Sat Aug 06, 2022 9:57 am
psycopathicteen wrote: Sat Aug 06, 2022 9:14 amThe games that use the same tables for A/B and C/D, are they perspectively accurate or are they distorted? Are they more accurate to slope-based perspective (straight lines remain straight), or by angle-based perspective (straight lines get curved)?
If done right they are accurately a perspective, of the slope-based kind you describe. They just can't do a perspective with arbitrary width and depth, because it makes the depth dependent on the width.

Though... now that I think about it, using A/B + C/D takes 2 HDMA channels. Using A + C + Y would take 3? I guess it's a tradeoff of computation time vs. HDMA channels.
If you're not using HDMA for m7vofs you will inevitabely loose at least some precision. The problem is that the hardware will automatically multiply the current scanline number with both B and D and that there are basically two ways to counteract that: Either you have something minus scanline in m7vofs, which is what my and psychopaticteens methods are both doing, or you need to divide what you have in B and D by the scanline number (which is what kulor is doing implicitly by scaling B and D and which is why he gets around having to set m7vofs). The problem with the latter approach is, that it reduces the precision that you can achieve with what you have in B and D (basically it reduces the value range from 16 bits to 8 bits) because in the worst case, you need to divide by 223.

This is also why with my method using all five registers, the camera can be moved around in sub pixel increments.
psycopathicteen wrote: Sat Aug 06, 2022 9:14 am I tried plugging it into Nova's mode 7 viewer, and for some reason the far away background is glitched.
I haven't verified it, but it might be this is caused by clipping whenever m7y and m7vofs are more than 1024 units apart.
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 »

I figured out the mystery curve problem. The issue is, I'm not sure why it works...
Basically, the mystery curve was a measurement of error between what my recentering was actually doing, and what my recentering needed to be doing; it wasn't an additional offset at all. The actual ideal recentering is much simpler.

Code: Select all

var camcenter = (-Math.tan(((cam.pitch - 90) * Math.PI) / 360) * normalizedHeight) * (cam.y / normalizedHeight);
I'd like to retouch my guide, but first I have to wrap my head around what exactly this is accomplishing and why...
Anyway, here's the full script, with new recentering:

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 = btma < 0;
	btma = Math.abs(btma);
	//centering offset (per-frame)
	var lineoffs = getm7y(cam, topa, btma, negedge);
	//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];
And some back-and-forth comparisons of Unity vs. my script:
Image

Image

Again, this is an "ideal" script, not optimized in any way. I'm working on what can be simplified into LUTs for the SNES, and I think I have an idea of how this whole thing can be done with absolutely zero actual per-line multiplies or divides, without compromising camera pitch/yaw/height control...still working on that though!
psycopathicteen
Posts: 3140
Joined: Wed May 19, 2010 6:12 pm

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by psycopathicteen »

none wrote: Sat Aug 06, 2022 6:55 pm
rainwarrior wrote: Sat Aug 06, 2022 9:57 am
psycopathicteen wrote: Sat Aug 06, 2022 9:14 amThe games that use the same tables for A/B and C/D, are they perspectively accurate or are they distorted? Are they more accurate to slope-based perspective (straight lines remain straight), or by angle-based perspective (straight lines get curved)?
If done right they are accurately a perspective, of the slope-based kind you describe. They just can't do a perspective with arbitrary width and depth, because it makes the depth dependent on the width.

Though... now that I think about it, using A/B + C/D takes 2 HDMA channels. Using A + C + Y would take 3? I guess it's a tradeoff of computation time vs. HDMA channels.
If you're not using HDMA for m7vofs you will inevitabely loose at least some precision. The problem is that the hardware will automatically multiply the current scanline number with both B and D and that there are basically two ways to counteract that: Either you have something minus scanline in m7vofs, which is what my and psychopaticteens methods are both doing, or you need to divide what you have in B and D by the scanline number (which is what kulor is doing implicitly by scaling B and D and which is why he gets around having to set m7vofs). The problem with the latter approach is, that it reduces the precision that you can achieve with what you have in B and D (basically it reduces the value range from 16 bits to 8 bits) because in the worst case, you need to divide by 223.

This is also why with my method using all five registers, the camera can be moved around in sub pixel increments.
psycopathicteen wrote: Sat Aug 06, 2022 9:14 am I tried plugging it into Nova's mode 7 viewer, and for some reason the far away background is glitched.
I haven't verified it, but it might be this is caused by clipping whenever m7y and m7vofs are more than 1024 units apart.
I think I figured out a formula for having B/D use the same scale factor as A/C.

Code: Select all

X = A(SX + BGX - CX) + B(SY + BGY - CY) + CX
Y = C(SX + BGX - CX) + D(SY + BGY - CY) + CY

P = Cz/(cos(b)d - sin(b)(Sy - 112))

A = P*cos(a)
B = P*sin(a)
C = -P*sin(a)
D = P*cos(a)

CX = sin(a)*Cam_z*(1 - cos(b))/sin(b) + Cam_x
CY = cos(a)*Cam_z*(1 - cos(b))/sin(b) + Cam_y

BGX = CX - 128
BGY = CY - 112 + d(1 - cos(b))/sin(b)
User avatar
rainwarrior
Posts: 8732
Joined: Sun Jan 22, 2012 12:03 pm
Location: Canada
Contact:

Re: Kulor's Guide to Mode 7 Perspective Planes

Post by rainwarrior »

Following up on what I was showing with the diagram in this earlier post, here is my version that generates the effect intended for ABCD HDMA.

So, instead of thinking about a camera, this takes as inputs:
  • Top and bottom scanlines to be rendered.
  • A top-width and bottom-width for the trapezoid, in texels (horizontal scale)
  • A height for the trapezoid, in texels (vertical scale)
  • A texel point for the bottom-centre point on the trapezoid (origin / pivot point)
  • An angle of rotation around that point
A camera frustum can be projected on the plane and turned into a trapezoid like this, but I wanted to be able to control the trapezoid directly.

This is partly because I want it to be absolutely explicit to me which texels on the tilemap are going to appear on the screen, but it's also because I wanted an approach that would be able to smoothly animate between perspective and a flat/square view (i.e. directly overhead), without having to think about a point of convergence in the distance which might end up approaching infinity.

So, what happens is that we interpolate w/perspective between the top and bottom of the trapezoid. The range of Z used for the perspective correction is directly proportional to the two horizontal lengths of the trapezoid, but importantly it can be rescaled by an arbitrary constant value which can help us keep it in a convenient range for computation. This generates A/C for the horizontal.

B/D use the same perspective scaling result, but the B/D coefficients would be pre-multiplied by their relative scale, i.e. how many texels high the trapezoid is vs. how wide (accounting for the range of view).

The overall computation should be 1 divide and 4 multiplies per HDMA line. If the horizontal and vertical texel scales are identical (e.g. how F-Zero and Final Fantasy VI do it), then we can just reuse A/C for B/D by negating B, and it cuts to 2 multiplies instead of 4. Either way, it takes two HDMA channels to upload ABCD.

(e.g. S0 = 256*3 and DV = (L1-L0)*3 would have identical texel scale of 3 texels per pixel, and could reuse A/C for B/D.)

I haven't yet built this on SNES, but here's code for Mode7Preview:

Code: Select all

// draws lines L0-L1
// ox,oy pins a pivot point at the bottom centre of last scanline (L1)
// angle spins around that pivot point
// has a row of S0 texels at top, S1 texels at bottom
// spans DV texels top to bottom

// var1 = angle * 6 degrees
// var2,var3 = ox,oy

L0 = 32         // start scanline
L1 = 224-32     // end scanline
S0 = 256*4      // texels along top scanline
S1 = 256*1      // texels along bottom scanline
DV = (L1-L0)*5  // texels from top to bottom

angle = var1 * 6 * 3.14 / 180
ox = 672 + var2
oy = 304 + var3

lerp = function(v0,v1,t) { return (v0*(1-t))+(v1*t); }

// "blank" outside rendering range
m7a = m7b = m7c = m7d =
m7x = m7y = m7hofs = m7vofs = 0

if (scanline >= L0 && scanline < L1)
{

// note: Z0/Z1 can be scaled by this arbitrary multiplier C,
//       which can be used to target a range of values/precision
//       suitable for our calculations.
C = 1
Z0 = S0 * C
Z1 = S1 * C
Sz0 = S0 / (256 * Z0)
Sz1 = S1 / (256 * Z1)

l = (scanline - L0) / (L1 - L0)
zr = lerp(1/Z0,1/Z1,l)
z = 1 / zr
sh = lerp(Sz0,Sz1,l) * z
sv = sh * ((DV / (L1 - L0)) / (S0 / 256))

a =  Math.cos(angle) * sh
b = -Math.sin(angle) * sv
c =  Math.sin(angle) * sh
d =  Math.cos(angle) * sv

m7a = toFixed(a)
m7b = toFixed(b)
m7c = toFixed(c)
m7d = toFixed(d)
m7x = ox
m7y = oy
m7hofs = ox - 128
m7vofs = oy - L1

}
return [m7a, m7b, m7c, m7d, m7x, m7y, m7hofs, m7vofs]
Last edited by rainwarrior on Sun Aug 07, 2022 11:41 pm, edited 1 time in total.
Post Reply