Interesting problem. I did not resist and code it for fun so here some insights... Well there are 2 basic approaches for this. One is raster fake and second is Vector based. I will describe the latter as you can do much more with it.
Transformations
You need vector math to transform points between world and camera space and back again. In 3D graphics are usually 4x4 homogenuous transform matrices used for this and many programing APIs support them natively. I will base my math on OpenGL matrix layout which determine the order of multiplication used. For more info I strongly recommend to read this:
As I use a lot from it. The linked answers there are also useful especially the 3D graphics pipeline and Full pseudo inverse matrix. The Answer itself is basic knowledge needed for 3D rendering in a nutshell (low level without the need for any lib apart of the rendering stuff).
There are also libs for this like GLM so if you want you can use any linear algebra supporting 4x4 matrices and 4D vectors instead of my code.
So lets have two 4x4
matrices one (camera
) representing our camera coordinate system and second (icamera
) which is its inverse. Now if we want to transform between world and screen space we simply do this:
P = camera*Q
Q = icamera*P
where P(x,y,z,1)
is point in camera coordinate system and Q(x,y,z,1)
is the same point in global world coordinate system.
Perspective
This is done simply by dividing P
by its z
coordinate. That will scale objects around (0,0)
so the more far object is the smaller will be. If we add some screen resolution and axis correction we can use this:
void perspective(double *P) // apply perspective transform on P
{
// perspectve division
P[0]*=znear/P[2];
P[1]*=znear/P[2];
// screen coordinate system
P[0]=xs2+P[0]; // move (0,0) to screen center
P[1]=ys2-P[1]; // axises: x=right, y=up
}
so point 0,0
is center of screen. The xs2,ys2
is half of resolution of the screen and znear
is focal length of the projection. So XY
plane rectangle with screen resolution and center at (0,0,znear)
will cover the screen exactly.
Rendering 3D line
We can use any primitives for rendering. I chose line as it is very simple and can achieve much. So what we want is to render 3D line using 2D line rendering API (of any kind). I am VCL based so I chose VCL/GDI Canvas
which should be very similar to your Canvas
.
So as input we got two 3D points in global world coordinate system. In order to render it with 2D line we need to convert the 3D position to 2D screen space. That is done by matrix*vector
multiplication.
From that we obtain two 3D points but in camera coordinate system. Now we need to clip the line by our view area (Frustrum). We can ignore x,y
axises as 2D line api usually does that for us anyway. So the only thing left is clip z
axis. Frustrum in z
axis is defined by znear
and zfar
. Where zfar
is our max visibility distance from camera focal point. So if our line is fully before or after our z-range
we ignore it and do not render. If it is inside we render it. If it crosses znear
or zfar
we cut the outside part off (by linear interpolation of the x,y
coordinates).
Now we just apply perspective on both points and render 2D line using their x,y
coordinates.
My code for this looks like this:
void draw_line(TCanvas *can,double *pA,double *pB) // draw 3D line
{
int i;
double D[3],A[3],B[3],t;
// transform to camera coordinate system
matrix_mul_vector(A,icamera,pA);
matrix_mul_vector(B,icamera,pB);
// sort points so A.z<B.z
if (A[2]>B[2]) for (i=0;i<3;i++) { D[i]=A[i]; A[i]=B[i]; B[i]=D[i]; }
// D = B-A
for (i=0;i<3;i++) D[i]=B[i]-A[i];
// ignore out of Z view lines
if (A[2]>zfar) return;
if (B[2]<znear) return;
// cut line to view if needed
if (A[2]<znear)
{
t=(znear-A[2])/D[2];
A[0]+=D[0]*t;
A[1]+=D[1]*t;
A[2]=znear;
}
if (B[2]>zfar)
{
t=(zfar-B[2])/D[2];
B[0]+=D[0]*t;
B[1]+=D[1]*t;
B[2]=zfar;
}
// apply perspective
perspective(A);
perspective(B);
// render
can->MoveTo(A[0],A[1]);
can->LineTo(B[0],B[1]);
}
Rendering XZ
plane
We can visualize the ground and sky planes using our 3D line as grid of squares. So we just create for
loops rendering the x
-axis aligned lines and y
-axis aligned lines covering some square of some size
around some origin position O
. The lines should be some step
far between each other equal to grid cell size.
The origin position O
should be near our frustrun center. If it would be constant then we could walk out of the plane edges so it woul dnot cover the whole (half)screen. We can use our camera position and add 0.5*(zfar+znear)*camera_z_axis
to it. To maintain the illusion of movement we need to align the O
to step
size. We can exploit floor
,round
or integer cast for this.
The resulting plane code looks like this:
void draw_plane_xz(TCanvas *can,double y,double step) // draw 3D plane
{
int i;
double A[3],B[3],t,size;
double U[3]={1.0,0.0,0.0}; // U = X
double V[3]={0.0,0.0,1.0}; // V = Z
double O[3]={0.0,0.0,0.0}; // Origin
// compute origin near view center but align to step
i=0; O[i]=floor(camera[12+i]/step)*step;
i=2; O[i]=floor(camera[12+i]/step)*step;
O[1]=y;
// set size so plane safely covers whole view
t=xs2*zfar/znear; size=t; // x that will convert to xs2 at zfar
t=0.5*(zfar+znear); if (size<t) size=t; // half of depth range
t+=step; // + one grid cell beacuse O is off up to 1 grid cell
t*=sqrt(2); // diagonal so no matter how are we rotate in Yaw
// U lines
for (i=0;i<3;i++)
{
A[i]=O[i]+(size*U[i])-((step+size)*V[i]);
B[i]=O[i]-(size*U[i])-((step+size)*V[i]);
}
for (t=-size;t<=size;t+=step)
{
for (i=0;i<3;i++)
{
A[i]+=step*V[i];
B[i]+=step*V[i];
}
draw_line(can,A,B);
}
// V lines
for (i=0;i<3;i++)
{
A[i]=O[i]-((step+size)*U[i])+(size*V[i]);
B[i]=O[i]-((step+size)*U[i])-(size*V[i]);
}
for (t=-size;t<=size;t+=step)
{
for (i=0;i<3;i++)
{
A[i]+=step*U[i];
B[i]+=step*U[i];
}
draw_line(can,A,B);
}
matrix_mul_vector(A,icamera,A);
}