Tutorial 28: Bezier-Kurven und Flächen
Engl. Orginalfassung: David Nikdel ( ogapo@ithink.net )
Ins Deutsche übersetzt: Hans-Jakob Schwer ( webmaster@codeworx.org )
Dieses Tutorial gibt im ersten Teil eine kurze Einführung in Bezier-Kurven,
danach wird es um die eigentlich interessanteren Bezier-Flächen ( 3D :)
gehen. Dabei wird es nicht allzu kompliziert werden, es geht vorallem darum
die Möglichkeiten von Bezier-Kurven an einem Beispieleffekt zu demonstrieren.
Ein wenig Mathe (...)
Bezier-Kurven (-Oberflächen) ohne ihre mathematische Grundlagen zu verstehen
ist kaum möglich, daher dieser kurze Exkurs. Sicherlich wird jeder Grafik-Interessierte
schon irgendwo Bezier-Kurven gesehen haben, sei es in Vektorprogrammen oder
Flash-Spielereien. Irgendwie sowas also:
Dies ist solch eine Bezier-Kurve, nur definiert durch 4 Punkte in einem 2-dimensionalen
Koordinatensystem. Zwei davon begrenzen die Kurve links und rechts, die anderen
beiden dienen zur Manipulatiomn der Krümmung.
Die einfachste Bezier-Kurve, im Grunde genommen eine Gerade, wird durch die
folgende Gleichung beschrieben:
t + (1 - t) = 1
In der Algebra werden solche Gebilde als Polynomen ersten Grades bezeichnet,
da die höchste Potenz über den Argumenten 1 ist ( t und t^1 sind identisch,
also könnte man auch t^1 + (1 - t^1) = 1 schreiben). Jetzt wird die Gleichung
um zwei Grade erhöht, auf beiden Seiten:
(t + (1-t))^3 = 1^3
Umgeformt ergäbe das: ( 1^1 = 1^3 ...)
t^3 + 3*t^2*(1-t) + 3*t*(1-t)^2 + (1-t)^3 = 1
Diese Gleichung wird sehr oft genutzt um Bezier-Kurven (3. Grades) zu berechnen,
aus zwei Gründen:
1. Die Kurve muss nicht zwingend in einer Ebene liegen, hat aber trotzdem einen
recht geringen Grad und ist damit einfach und "ressourcensparend"
benutzbar.
2. Die beiden Tangenten können völlg unabhängig von einander
angelegt werden, da sie jeweils einen eigenen Start- und Endpunkt haben.
Alles was auf der rechten Seite eingesetzt wird, muss 1 ergeben damit die Gleichung
erfüllt wird. Aber wie werden jetzt die Punkte entlang der Kurve berechnet?
Für t müssen Werte eingesetzt werden die größer gleich
0 sind und kleiner gleich 1 sind. Um jetzt die 4 Kontrollpunkte ins Spiel zu
bringen müssen die Einzelterme innerhalb der Gleichung noch mit diesen
multipliziert werden, nach diesem Schema: (Die Punkte werden durch P1 bis P4
repräsentiert)
P1*t^3 + P2*3*t^2*(1-t) + P3*3*t*(1-t)^2 + P4*(1-t)^3 = Pnew
Da Polynome stetig sind, also nicht unterbrochen werden, wird eine solche Kurve
immer von P1 nach P4 laufen (P1 bei t = 0 und P4 bei t = 1).
Das zum mathematischen Hintergrund der Bezier-Kurven, Ziel soll es aber sein
eine 3D-Fläche zu erzeugen.
Bezier-Flächen
Um eine Bezier-Fläche darzustellen bedarf es 16 Kontrollpunkten (4*4)
und, zusätzlich zu t, einer weiteren Variable v. Auch v darf nur Werte
wischen 0 und 1 enthalten. Dabei ist es sicherlich sinnvoll v zuerst 0 zu setzen,
in bestimmten Abständen dann Punkte für t zwischen 0 und 1 zu erzeugen
um danach t schrittweise zu erhöhen bis beide 1 werden.
Aber nun zum Code:
#include <windows.h> // Header File For Windows
#include <math.h> // Header File For Math Library Routines
#include <stdio.h> // Header File For Standard I/O Routines
#include <stdlib.h> // Header File For Standard Library
#include <gl\gl.h> // Header File For The OpenGL32 Library
#include <gl\glu.h> // Header File For The GLu32 Library
#include <gl\glaux.h> // Header File For The Glaux Library
typedef struct point_3d { // Structure For A 3-Dimensional Point ( NEW )
double x, y, z;
} POINT_3D;
typedef struct bpatch { // Structure For A 3rd Degree Bezier Patch ( NEW )
POINT_3Danchors[4][4]; // 4x4 Grid Of Anchor Points
GLuintdlBPatch; // Display List For Bezier Patch
GLuinttexture; // Texture For The Patch
} BEZIER_PATCH;
HDChDC=NULL; // Private GDI Device Context
HGLRChRC=NULL; // Permanent Rendering Context
HWNDhWnd=NULL; // Holds Our Window Handle
HINSTANCEhInstance; // Holds The Instance Of The Application
DEVMODEDMsaved; // Saves The Previous Screen Settings ( NEW )
boolkeys[256]; // Array Used For The Keyboard Routine
boolactive=TRUE; // Window Active Flag Set To TRUE By Default
boolfullscreen=TRUE; // Fullscreen Flag Set To Fullscreen Mode By Default
GLfloatrotz = 0.0f; // Rotation About The Z Axis
BEZIER_PATCHmybezier; // The Bezier Patch We're Going To Use ( NEW )
BOOLshowCPoints=TRUE; // Toggles Displaying The Control Point Grid ( NEW )
intdivs = 7; // Number Of Intrapolations (Controls Poly Resolution) ( NEW )
LRESULTCALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);// Declaration For WndProc
Hier einige grundlegenden Funktionen um mit Vektoren umzugehen. Es gibt natürlich
auch maßgeschneiderte C++-Klassen die ähnliches bieten:
// Adds 2 Points. Don't Just Use '+' ;)
POINT_3D pointAdd(POINT_3D p, POINT_3D q)
{
p.x += q.x;p.y += q.y;p.z += q.z;
return p;
}
// Multiplies A Point And A Constant. Don't Just Use '*'
POINT_3D pointTimes(double c, POINT_3D p)
{
p.x *= c;p.y *= c;p.z *= c;
return p;
}
// Function For Quick Point Creation
POINT_3D makePoint(double a, double b, double c)
{
POINT_3D p;
p.x = a;p.y = b;p.z = c;
return p;
}
Hier jetzt der Kern des Ganzen, die Gleichung um die Punkte auf der Fläche
zu berechnen. Übergeben wird u und ein Array aus 4 Punkten:
// Calculates 3rd Degree Polynomial Based On Array Of 4 Points
// And A Single Variable (u) Which Is Generally Between 0 And 1
POINT_3D Bernstein(float u, POINT_3D *p)
{
POINT_3Da, b, c, d, r;
a = pointTimes(pow(u,3), p[0]);
b = pointTimes(3*pow(u,2)*(1-u), p[1]);
c = pointTimes(3*u*pow((1-u),2), p[2]);
d = pointTimes(pow((1-u),3), p[3]);
r = pointAdd(pointAdd(a, b), pointAdd(c, d));
return r;
}
Der Löwenanteil wird von dieser Gleichung berechnet. Erzeugt wird ein
triangle strip, also eine Ansammlung von aneinandergepappten Dreiecken, der
aus Performance-Gründen in einer display list gespeichert werden. Die Fläche
muss daher auch nicht in jedem Frame neu berechnet werden, nur wenn es Veränderungen
gibt. Man könnte jetzt zum Beispiel die Kontrollpunkte bewegen um einen
sehr netten organischen Effekt zu erreichen.
Das Array "last" speichert die Punkte des letzten Durchgangs, da
triangle strips auch diese Werte brauchen. Die Texturkoordinaten u und v werden
ebenfalls jedesmal neu berechnet.
Die Normalen werden nicht generiert, da diese vorallem für die Beleuchtung
notwendig sind, es in diesem Tut aber eher nicht um Beleuchtungseffekte gehen
soll.
// Generates A Display List Based On The Data In The Patch
// And The Number Of Divisions
GLuint genBezier(BEZIER_PATCH patch, int divs)
{
intu = 0, v;
floatpy, px, pyold;
GLuintdrawlist = glGenLists(1);// Make The Display List
POINT_3Dtemp[4];
POINT_3D*last = (POINT_3D*)malloc(sizeof(POINT_3D)*(divs+1));
// Array Of Points To Mark The First Line Of Polys
if (patch.dlBPatch != NULL)// Get Rid Of Any Old Display Lists
glDeleteLists(patch.dlBPatch, 1);
temp[0] = patch.anchors[0][3];// The First Derived Curve (Along X-Axis)
temp[1] = patch.anchors[1][3];
temp[2] = patch.anchors[2][3];
temp[3] = patch.anchors[3][3];
for (v=0;v<=divs;v++)
{ // Create The First Line Of Points
px = ((float)v)/((float)divs);// Percent Along Y-Axis
// Use The 4 Points From The Derived Curve To Calculate The Points Along That Curve
last[v] = Bernstein(px, temp);
}
glNewList(drawlist, GL_COMPILE);// Start A New Display List
glBindTexture(GL_TEXTURE_2D, patch.texture);// Bind The Texture
for (u=1;u<=divs;u++)
{
py = ((float)u)/((float)divs);// Percent Along Y-Axis
pyold = ((float)u-1.0f)/((float)divs);// Percent Along Old Y Axis
temp[0] = Bernstein(py, patch.anchors[0]);// Calculate New Bezier Points
temp[1] = Bernstein(py, patch.anchors[1]);
temp[2] = Bernstein(py, patch.anchors[2]);
temp[3] = Bernstein(py, patch.anchors[3]);
glBegin(GL_TRIANGLE_STRIP);// Begin A New Triangle Strip
for (v=0;v<=divs;v++)
{
px = ((float)v)/((float)divs);// Percent Along The X-Axis
glTexCoord2f(pyold, px);// Apply The Old Texture Coords
glVertex3d(last[v].x, last[v].y, last[v].z);// Old Point
last[v] = Bernstein(px, temp);// Generate New Point
glTexCoord2f(py, px);// Apply The New Texture Coords
glVertex3d(last[v].x, last[v].y, last[v].z);// New Point
}
glEnd();// END The Triangle Strip
}
glEndList();// END The List
free(last);// Free The Old Vertices Array
return drawlist;// Return The Display List
}
Hier werden die 4*4 Kontrollpunkte festgelegt, natürlich können diese
beliebig verändert werden:
void initBezier(void)
{
mybezier.anchors[0][0] = makePoint(-0.75,-0.75,-0.50);
mybezier.anchors[0][1] = makePoint(-0.25,-0.75, 0.00);
mybezier.anchors[0][2] = makePoint( 0.25,-0.75, 0.00);
mybezier.anchors[0][3] = makePoint( 0.75,-0.75,-0.50);
mybezier.anchors[1][0] = makePoint(-0.75,-0.25,-0.75);
mybezier.anchors[1][1] = makePoint(-0.25,-0.25, 0.50);
mybezier.anchors[1][2] = makePoint( 0.25,-0.25, 0.50);
mybezier.anchors[1][3] = makePoint( 0.75,-0.25,-0.75);
mybezier.anchors[2][0] = makePoint(-0.75, 0.25, 0.00);
mybezier.anchors[2][1] = makePoint(-0.25, 0.25,-0.50);
mybezier.anchors[2][2] = makePoint( 0.25, 0.25,-0.50);
mybezier.anchors[2][3] = makePoint( 0.75, 0.25, 0.00);
mybezier.anchors[3][0] = makePoint(-0.75, 0.75,-0.50);
mybezier.anchors[3][1] = makePoint(-0.25, 0.75,-1.00);
mybezier.anchors[3][2] = makePoint( 0.25, 0.75,-1.00);
mybezier.anchors[3][3] = makePoint( 0.75, 0.75,-0.50);
mybezier.dlBPatch = NULL; // Go Ahead And Initialize This To NULL
}
Die optimierte Version der Funktion zum Laden von Bitmaps:
// Load Bitmaps And Convert To Textures
BOOL LoadGLTexture(GLuint *texPntr, char* name)
{
BOOL success = FALSE;
AUX_RGBImageRec *TextureImage = NULL;
glGenTextures(1, texPntr); // Generate 1 Texture
FILE* test=NULL;
TextureImage = NULL;
test = fopen(name, "r"); // Test To See If The File Exists
if (test != NULL)
{ // If It Does
fclose(test);// Close The File
TextureImage = auxDIBImageLoad(name);
// And Load The Texture
}
if (TextureImage != NULL)
{ // If It Loaded
success = TRUE;
// Typical Texture Generation Using Data From The Bitmap
glBindTexture(GL_TEXTURE_2D, *texPntr);
glTexImage2D(GL_TEXTURE_2D, 0, 3, TextureImage->sizeX, TextureImage->sizeY,
0, GL_RGB, GL_UNSIGNED_BYTE, TextureImage->data);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
}
if (TextureImage->data)
free(TextureImage->data);
return success;
}
Hier wird OpenGL und die Bezier-Fläche initialisiert:
int InitGL(GLvoid)// All Setup For OpenGL Goes Here
{
glEnable(GL_TEXTURE_2D);// Enable Texture Mapping
glShadeModel(GL_SMOOTH);// Enable Smooth Shading
glClearColor(0.05f, 0.05f, 0.05f, 0.5f);// Black Background
glClearDepth(1.0f);// Depth Buffer Setup
glEnable(GL_DEPTH_TEST);// Enables Depth Testing
glDepthFunc(GL_LEQUAL);// The Type Of Depth Testing To Do
glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST);// Really Nice Perspective Calculations
initBezier();// Initialize the Bezier's Control Grid ( NEW )
LoadGLTexture(&(mybezier.texture), "./Data/NeHe.bmp");// Load The Texture ( NEW )
mybezier.dlBPatch = genBezier(mybezier, divs);// Generate The Patch ( NEW )
return TRUE;// Initialization Went OK
}
Die Kontrollgeraden werden durch rote Striche in die fertige Szene eingezeichnet.
Mit der Leertaste läßt sich das an- und ausschalten:
int DrawGLScene(GLvoid) // Here's Where We Do All The Drawing
{
int i, j;
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Clear Screen And Depth Buffer
glLoadIdentity();// Reset The Current Modelview Matrix
glTranslatef(0.0f,0.0f,-4.0f);// Move Left 1.5 Units And Into The Screen 6.0
glRotatef(-75.0f,1.0f,0.0f,0.0f);
glRotatef(rotz,0.0f,0.0f,1.0f);// Rotate The Triangle On The Z-Axis
glCallList(mybezier.dlBPatch);// Call The Bezier's Display List
// This Need Only Be Updated When The Patch Changes
if (showCPoints)
{ // If Drawing The Grid Is Toggled On
glDisable(GL_TEXTURE_2D);
glColor3f(1.0f,0.0f,0.0f);
for(i=0;i<4;i++)
{// Draw The Horizontal Lines
glBegin(GL_LINE_STRIP);
for(j=0;j<4;j++)
glVertex3d(mybezier.anchors[i][j].x,
mybezier.anchors[i][j].y,
mybezier.anchors[i][j].z);
glEnd();
}
for(i=0;i<4;i++)
{// Draw The Vertical Lines
glBegin(GL_LINE_STRIP);
for(j=0;j<4;j++)
glVertex3d(mybezier.anchors[j][i].x,
mybezier.anchors[j][i].y,
mybezier.anchors[j][i].z);
glEnd();
}
glColor3f(1.0f,1.0f,1.0f);
glEnable(GL_TEXTURE_2D);
}
return TRUE;// Keep Going
}
Es wurden noch einige Modifikationen an KillGLWindow() und CreateGLWindow()
vorgenommen um ein Problem zu beheben das bei bestimmten älteren Grafikkarten
auftritt. (Die Änderungen müssen allerdings nicht vorgenommen, da
sie für dieses Tutorial eigentlich nicht relevant sind.
GLvoid KillGLWindow(GLvoid)// Properly Kill The Window
{
if (fullscreen)// Are We In Fullscreen Mode?
{
if (!ChangeDisplaySettings(NULL,CDS_TEST))
{ // If The Shortcut Doesn't Work ( NEW )
ChangeDisplaySettings(NULL,CDS_RESET);// Do It Anyway (To Get The Values Out Of The Registry) ( NEW )
ChangeDisplaySettings(&DMsaved,CDS_RESET);// Change It To The Saved Settings ( NEW )
}
else
{
ChangeDisplaySettings(NULL,CDS_RESET);// If It Works, Go Right Ahead ( NEW )
}
ShowCursor(TRUE);// Show Mouse Pointer
}
if (hRC)// Do We Have A Rendering Context?
{
if (!wglMakeCurrent(NULL,NULL))// Are We Able To Release The DC And RC Contexts?
{
MessageBox(NULL,"Release Of DC And RC Failed.","SHUTDOWN ERROR",MB_OK | MB_ICONINFORMATION);
}
if (!wglDeleteContext(hRC))// Are We Able To Delete The RC?
{
MessageBox(NULL,"Release Rendering Context Failed.","SHUTDOWN ERROR",MB_OK | MB_ICONINFORMATION);
}
hRC=NULL;// Set RC To NULL
}
if (hDC && !ReleaseDC(hWnd,hDC))// Are We Able To Release The DC
{
MessageBox(NULL,"Release Device Context Failed.","SHUTDOWN ERROR",MB_OK | MB_ICONINFORMATION);
hDC=NULL;// Set DC To NULL
}
if (hWnd && !DestroyWindow(hWnd))// Are We Able To Destroy The Window?
{
MessageBox(NULL,"Could Not Release hWnd.","SHUTDOWN ERROR",MB_OK | MB_ICONINFORMATION);
hWnd=NULL;// Set hWnd To NULL
}
if (!UnregisterClass("OpenGL",hInstance))// Are We Able To Unregister Class
{
MessageBox(NULL,"Could Not Unregister Class.","SHUTDOWN ERROR",MB_OK | MB_ICONINFORMATION);
hInstance=NULL;// Set hInstance To NULL
}
}
EnumDisplaySettings() wurde hinzugefügt um das Grafikkartenproblem zu lösen:
// This Code Creates Our OpenGL Window. Parameters Are:*
// title- Title To Appear At The Top Of The Window*
// width- Width Of The GL Window Or Fullscreen Mode*
// height- Height Of The GL Window Or Fullscreen Mode*
// bits- Number Of Bits To Use For Color (8/16/24/32)*
// fullscreenflag- Use Fullscreen Mode (TRUE) Or Windowed Mode (FALSE)*/
BOOL CreateGLWindow(char* title, int width, int height, int bits, bool fullscreenflag)
{
GLuintPixelFormat;// Holds The Results After Searching For A Match
WNDCLASSwc;// Windows Class Structure
DWORDdwExStyle;// Window Extended Style
DWORDdwStyle;// Window Style
RECTWindowRect;// Grabs Rectangle Upper Left / Lower Right Values
WindowRect.left=(long)0;// Set Left Value To 0
WindowRect.right=(long)width;// Set Right Value To Requested Width
WindowRect.top=(long)0;// Set Top Value To 0
WindowRect.bottom=(long)height;// Set Bottom Value To Requested Height
fullscreen=fullscreenflag;// Set The Global Fullscreen Flag
hInstance= GetModuleHandle(NULL);// Grab An Instance For Our Window
wc.style= CS_HREDRAW | CS_VREDRAW | CS_OWNDC;// Redraw On Size, And Own DC For Window
wc.lpfnWndProc= (WNDPROC) WndProc;// WndProc Handles Messages
wc.cbClsExtra= 0;// No Extra Window Data
wc.cbWndExtra= 0;// No Extra Window Data
wc.hInstance= hInstance;// Set The Instance
wc.hIcon= LoadIcon(NULL, IDI_WINLOGO);// Load The Default Icon
wc.hCursor= LoadCursor(NULL, IDC_ARROW);// Load The Arrow Pointer
wc.hbrBackground= NULL;// No Background Required For GL
wc.lpszMenuName= NULL;// We Don't Want A Menu
wc.lpszClassName= "OpenGL";// Set The Class Name
EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &DMsaved);// Save The Current Display State ( NEW )
if (fullscreen)// Attempt Fullscreen Mode?
{
DEVMODE dmScreenSettings;// Device Mode
memset(&dmScreenSettings,0,sizeof(dmScreenSettings));// Makes Sure Memory's Cleared
dmScreenSettings.dmSize=sizeof(dmScreenSettings);// Size Of The Devmode Structure
dmScreenSettings.dmPelsWidth= width;// Selected Screen Width
dmScreenSettings.dmPelsHeight= height;// Selected Screen Height
dmScreenSettings.dmBitsPerPel= bits;// Selected Bits Per Pixel
dmScreenSettings.dmFields=DM_BITSPERPEL|DM_PELSWIDTH|DM_PELSHEIGHT;
// Der Rest der Funktion bleibt unverändert
(........)
Es wurden noch einige Zeilen eingefügt um die Figur zu drehen, die Details
zu erhöhen/verringern und die roten Kontrolllinien mit der Leertaste an-
und abzuschalten:
int WINAPI WinMain(HINSTANCEhInstance, // Instance
HINSTANCEhPrevInstance, // Previous Instance
LPSTRlpCmdLine, // Command Line Parameters
int nCmdShow) // Window Show State
{
MSGmsg;// Windows Message Structure
BOOLdone=FALSE;// Bool Variable To Exit Loop
// Ask The User Which Screen Mode They Prefer
if (MessageBox(NULL,"Would You Like To Run In Fullscreen Mode?", "Start FullScreen?",MB_YESNO|MB_ICONQUESTION)==IDNO)
{
fullscreen=FALSE;// Windowed Mode
}
// Create Our OpenGL Window
if (!CreateGLWindow("NeHe's Solid Object Tutorial",640,480,16,fullscreen))
{
return 0;// Quit If Window Was Not Created
}
while(!done)// Loop That Runs While done=FALSE
{
if (PeekMessage(&msg,NULL,0,0,PM_REMOVE))// Is There A Message Waiting?
{
if (msg.message==WM_QUIT)// Have We Received A Quit Message?
{
done=TRUE;// If So done=TRUE
}
else// If Not, Deal With Window Messages
{
TranslateMessage(&msg);// Translate The Message
DispatchMessage(&msg);// Dispatch The Message
}
}
else// If There Are No Messages
{
// Draw The Scene. Watch For ESC Key And Quit Messages From DrawGLScene()
if ((active && !DrawGLScene()) || keys[VK_ESCAPE])
// Active? Was There A Quit Received?
{
done=TRUE;// ESC or DrawGLScene Signalled A Quit
}
else// Not Time To Quit, Update Screen
{
SwapBuffers(hDC);// Swap Buffers (Double Buffering)
}
if (keys[VK_LEFT])rotz -= 0.8f;// Rotate Left ( NEW )
if (keys[VK_RIGHT])rotz += 0.8f;// Rotate Right ( NEW )
if (keys[VK_UP]) {// Resolution Up ( NEW )
divs++;
mybezier.dlBPatch = genBezier(mybezier, divs);// Update The Patch
keys[VK_UP] = FALSE;
}
if (keys[VK_DOWN] && divs > 1)
{// Resolution Down ( NEW )
divs--;
mybezier.dlBPatch = genBezier(mybezier, divs);// Update The Patch
keys[VK_DOWN] = FALSE;
}
if (keys[VK_SPACE])
{// SPACE Toggles showCPoints ( NEW )
showCPoints = !showCPoints;
keys[VK_SPACE] = FALSE;
}
if (keys[VK_F1])// Is F1 Being Pressed?
{
keys[VK_F1]=FALSE;// If So Make Key FALSE
KillGLWindow();// Kill Our Current Window
fullscreen=!fullscreen;// Toggle Fullscreen / Windowed Mode
// Recreate Our OpenGL Window
if (!CreateGLWindow("NeHe's Solid Object Tutorial",640,480,16,fullscreen))
{
return 0;// Quit If Window Was Not Created
}
}
}
}
// Shutdown
KillGLWindow();// Kill The Window
return (msg.wParam);// Exit The Program
}
Vielen Dank fürs Durcharbeiten dieses Tutorials, das Thema ist (wie immer
;) stark mathematisch gefärbt, aber eigentlich grundsätzlich "verstehbar".
David Nikdel
Jeff Molofee (NeHe) http://nehe.gamedev.net
Die Source Codes und Ausführbaren Dateien zu den Kursen liegen auf der Neon
Helium Website
Übersetzt und leicht modifiziert von Hans-Jakob Schwer 19.04.2k3, www.codeworx.org