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.
Vector approach
This approach is not faking anything it really is 3D. The rest depends on the rendering you want to use this for... For now I assume you can render 2D lines. All the code chunks are in C++.
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);
}
Now if I put all this together in small VCL/GDI/Canvas application I got this:
//---------------------------------------------------------------------------
#include <vcl.h> // you can ignore these lines
#include <math.h>
#pragma hdrstop
#include "win_main.h"
//---------------------------------------------------------------------------
#pragma package(smart_init)
#pragma resource "*.dfm" // up to here.
TMain *Main; // this is pointer to my VCL window (you do not need it)
//--- Here starts the important stuff: --------------------------------------
// perspective
double znear= 100.0; // focal length for perspective
double zfar = 2100.0; // visibility
// view
double xs2=0.0; // screen half resolution
double ys2=0.0;
// camera
double yaw=0.0; // euler yaw angle [rad]
double camera[16]; // camera direct transform matrix
double icamera[16]; // camera inverse transform matrix
// keyboard bools
bool _forw=false,_back=false,_right=false,_left=false;
//---------------------------------------------------------------------------
void matrix_inv(double *a,double *b) // a[16] = Inverse(b[16])
{
double x,y,z;
// transpose of rotation matrix
a[ 0]=b[ 0];
a[ 5]=b[ 5];
a[10]=b[10];
x=b[1]; a[1]=b[4]; a[4]=x;
x=b[2]; a[2]=b[8]; a[8]=x;
x=b[6]; a[6]=b[9]; a[9]=x;
// copy projection part
a[ 3]=b[ 3];
a[ 7]=b[ 7];
a[11]=b[11];
a[15]=b[15];
// convert origin: new_pos = - new_rotation_matrix * old_pos
x=(a[ 0]*b[12])+(a[ 4]*b[13])+(a[ 8]*b[14]);
y=(a[ 1]*b[12])+(a[ 5]*b[13])+(a[ 9]*b[14]);
z=(a[ 2]*b[12])+(a[ 6]*b[13])+(a[10]*b[14]);
a[12]=-x;
a[13]=-y;
a[14]=-z;
}
//---------------------------------------------------------------------------
void matrix_mul_vector(double *c,double *a,double *b) // c[3] = a[16]*b[3]
{
double q[3];
q[0]=(a[ 0]*b[0])+(a[ 4]*b[1])+(a[ 8]*b[2])+(a[12]);
q[1]=(a[ 1]*b[0])+(a[ 5]*b[1])+(a[ 9]*b[2])+(a[13]);
q[2]=(a[ 2]*b[0])+(a[ 6]*b[1])+(a[10]*b[2])+(a[14]);
for(int i=0;i<3;i++) c[i]=q[i];
}
//---------------------------------------------------------------------------
void compute_matrices() // recompute camera,icamera after camera position or yaw change
{
// bound angle
while (yaw>2.0*M_PI) yaw-=2.0*M_PI;
while (yaw<0.0 ) yaw+=2.0*M_PI;
// X = right
camera[ 0]= cos(yaw);
camera[ 1]= 0.0 ;
camera[ 2]= sin(yaw);
// Y = up
camera[ 4]= 0.0 ;
camera[ 5]= 1.0 ;
camera[ 6]= 0.0 ;
// Z = forward
camera[ 8]=-sin(yaw);
camera[ 9]= 0.0 ;
camera[10]= cos(yaw);
// no projection
camera[ 3]= 0.0 ;
camera[ 7]= 0.0 ;
camera[11]= 0.0 ;
camera[15]= 1.0 ;
// compute the inverse matrix
matrix_inv(icamera,camera);
}
//---------------------------------------------------------------------------
void perspective(double *P) // apply perspective transform
{
// 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
}
//---------------------------------------------------------------------------
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]);
}
//---------------------------------------------------------------------------
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);
}
//---------------------------------------------------------------------------
void TMain::draw() // this is my main rendering routine
{
// clear buffer
bmp->Canvas->Brush->Color=clWhite;
bmp->Canvas->FillRect(TRect(0,0,xs,ys));
// init/update variables
double step= 50.0; // plane grid size
::xs2=Main->xs2; // update actual screen half resolution
::ys2=Main->ys2;
// sky
bmp->Canvas->Pen->Color=clBlue;
draw_plane_xz(bmp->Canvas,+200.0,step);
// terrain
bmp->Canvas->Pen->Color=clGreen;
draw_plane_xz(bmp->Canvas,-200.0,step);
// render backbuffer
Main->Canvas->Draw(0,0,bmp);
_redraw=false;
}
//---------------------------------------------------------------------------
__fastcall TMain::TMain(TComponent* Owner) : TForm(Owner) // this is initialization
{
bmp=new Graphics::TBitmap;
bmp->HandleType=bmDIB;
bmp->PixelFormat=pf32bit;
pyx=NULL;
_redraw=true;
// camera start position
camera[12]=0.0;
camera[13]=0.0;
camera[14]=0.0;
compute_matrices();
}
//---------------------------------------------------------------------------
void __fastcall TMain::FormDestroy(TObject *Sender) // this is exit
{
if (pyx) delete[] pyx;
delete bmp;
}
//---------------------------------------------------------------------------
void __fastcall TMain::FormResize(TObject *Sender) // this is called on resize
{
xs=ClientWidth; xs2=xs>>1;
ys=ClientHeight; ys2=ys>>1;
bmp->Width=xs;
bmp->Height=ys;
if (pyx) delete[] pyx;
pyx=new int*[ys];
for (int y=0;y<ys;y++) pyx[y]=(int*) bmp->ScanLine[y];
_redraw=true;
}
//---------------------------------------------------------------------------
void __fastcall TMain::FormPaint(TObject *Sender) // this is called on forced repaint
{
_redraw=true;
}
//---------------------------------------------------------------------------
void __fastcall TMain::tim_redrawTimer(TObject *Sender) // this is called periodically by my timer
{
double da=5.0*M_PI/180.0; // turn speed
double dl=15.0; // movement speed
bool _recompute=false;
if (_left ) { _redraw=true; _recompute=true; yaw+=da; }
if (_right) { _redraw=true; _recompute=true; yaw-=da; }
if (_forw ) { _redraw=true; _recompute=true; for (int i=0;i<3;i++) camera[12+i]+=dl*camera[8+i]; }
if (_back ) { _redraw=true; _recompute=true; for (int i=0;i<3;i++) camera[12+i]-=dl*camera[8+i]; }
if (_recompute) compute_matrices();
if (_redraw) draw();
}
//---------------------------------------------------------------------------
void __fastcall TMain::FormKeyDown(TObject *Sender, WORD &Key,TShiftState Shift) // this is called when key is pushed
{
//Caption=Key;
if (Key==104) _left=true;
if (Key==105) _right=true;
if (Key==100) _forw=true;
if (Key== 97) _back=true;
}
//---------------------------------------------------------------------------
void __fastcall TMain::FormKeyUp(TObject *Sender, WORD &Key, TShiftState Shift) // this is called when key is released
{
if (Key==104) _left=false;
if (Key==105) _right=false;
if (Key==100) _forw=false;
if (Key== 97) _back=false;
}
//---------------------------------------------------------------------------
Here the Form header file (you do not really need it unless you are reconstructin my VCL app)
//---------------------------------------------------------------------------
#ifndef win_mainH
#define win_mainH
//---------------------------------------------------------------------------
#include <Classes.hpp>
#include <Controls.hpp>
#include <StdCtrls.hpp>
#include <Forms.hpp>
#include <ComCtrls.hpp>
#include <ExtCtrls.hpp>
//---------------------------------------------------------------------------
class TMain : public TForm
{
__published: // IDE-managed Components
TTimer *tim_redraw;
void __fastcall FormResize(TObject *Sender);
void __fastcall FormPaint(TObject *Sender);
void __fastcall FormDestroy(TObject *Sender);
void __fastcall tim_redrawTimer(TObject *Sender);
void __fastcall FormKeyDown(TObject *Sender, WORD &Key, TShiftState Shift);
void __fastcall FormKeyUp(TObject *Sender, WORD &Key, TShiftState Shift);
private: // User declarations
public: // User declarations
__fastcall TMain(TComponent* Owner);
void draw();
int xs,ys,xs2,ys2,**pyx;
Graphics::TBitmap *bmp;
bool _redraw;
};
//---------------------------------------------------------------------------
extern PACKAGE TMain *Main;
//---------------------------------------------------------------------------
#endif
The VCL app is just single Form with single timer (100ms
) on it and no other VCL components. The bmp
is just my backbuffer bitmap to avoid flickering. The keyboard events are just to enable turning and movement (with numpad 8,9,4,1
).
Here preview of above code:

Now if you want to add the whiteout visibility limiter that is done by Fog or Volumetric fog. You simply interpolate between rendered color and White based on parameter t
:
t = (z-znear)/(zfar-znear); // t = <0,1>
where z
is pixel coordinate in camera space so:
color = color*(1.0-t) + White*t;
But to apply this in here we would need to encode the 2D line rasterizer or have 2D line api with per vertex color (like OpenGL). Another option is to fake it by blending in a Fog image which is fully solid near center line and fully transparent on the top and bottom edges.