spinning_cube - ryzom/ryzomcore GitHub Wiki


title: Draw 3D primitives and control the camera description: Learn to draw colored 3D geometry, set up a perspective camera, and animate with delta time published: true date: 2026-03-14T00:00:00.000Z tags: editor: markdown dateCreated: 2026-03-14T00:00:00.000Z

In the previous tutorials we set up a project, created a game loop, and rendered text. Now we'll draw actual 3D geometry: a spinning colored cube with an orbiting camera, all using the NeL driver's immediate-mode drawing functions.

This tutorial covers:

  • Creating materials for 3D drawing
  • Setting up a perspective frustum and view matrix
  • Drawing colored quads in 3D space
  • Delta-time animation
  • Keyboard input for camera control

Concepts

NeL provides two ways to render 3D content. The scene graph (UScene) manages shapes, lights, and animations automatically. For simple cases, though, you can draw primitives directly through the driver using drawQuad, drawLine, and similar functions. This tutorial uses the direct approach, which requires you to set up the camera matrices yourself.

In NeL's coordinate system, X is right, Y is forward, and Z is up.

Setting up materials

A UMaterial controls how geometry is drawn. For unlit colored geometry, call initUnlit(). You also need to configure depth testing so that closer faces draw over farther ones.

Add two materials to the game class:

#include <nel/3d/u_material.h>
private:
	NL3D::UMaterial m_CubeMat;
	NL3D::UMaterial m_SkyMat;

Create them after the driver is initialized:

	// Opaque material for the cube
	m_CubeMat = m_Driver->createMaterial();
	m_CubeMat.initUnlit();
	m_CubeMat.setZWrite(true);
	m_CubeMat.setZFunc(NL3D::UMaterial::lessequal);

	// Background material: no depth write, always passes
	m_SkyMat = m_Driver->createMaterial();
	m_SkyMat.initUnlit();
	m_SkyMat.setZWrite(false);
	m_SkyMat.setZFunc(NL3D::UMaterial::always);

Delete them in the destructor, before releasing the driver:

	m_Driver->deleteMaterial(m_CubeMat);
	m_Driver->deleteMaterial(m_SkyMat);

Setting up the camera

To render 3D geometry, you need to configure three things on the driver: a frustum (the projection), a view matrix (where the camera is), and a model matrix (where the object is).

Frustum

A CFrustum defines the perspective projection. Add the include and initialize it each frame based on the window size:

#include <nel/3d/frustum.h>
	uint32 screenW, screenH;
	m_Driver->getWindowSize(screenW, screenH);

	NL3D::CFrustum frustum;
	frustum.initPerspective(float(NLMISC::Pi / 3.0),
		float(screenW) / float(screenH), 0.1f, 100.f);
	m_Driver->setFrustum(frustum);

View matrix

The view matrix positions and orients the camera. Build it from an eye position, a target to look at, and an up vector. NeL's CMatrix doesn't have a built-in lookAt, so we construct it manually:

static NLMISC::CMatrix buildViewMatrix(
	const NLMISC::CVector &eye,
	const NLMISC::CVector &target,
	const NLMISC::CVector &up)
{
	NLMISC::CVector jj = (target - eye).normed(); // forward
	NLMISC::CVector ii = (jj ^ up).normed();      // right
	NLMISC::CVector kk = ii ^ jj;                 // corrected up
	NLMISC::CMatrix camWorld;
	camWorld.setRot(ii, jj, kk, true);
	camWorld.setPos(eye);
	NLMISC::CMatrix viewMatrix = camWorld;
	viewMatrix.invert();
	return viewMatrix;
}

Then each frame:

	NLMISC::CVector eye(cosf(m_CamAngle) * m_CamDist,
		sinf(m_CamAngle) * m_CamDist, 3.f);
	NLMISC::CVector target(0.f, 0.f, 1.f);
	NLMISC::CVector up(0.f, 0.f, 1.f);

	m_Driver->setViewMatrix(buildViewMatrix(eye, target, up));

Model matrix

The model matrix transforms object vertices into world space. For world-space drawing, set it to identity:

	NLMISC::CMatrix modelMatrix;
	modelMatrix.identity();
	m_Driver->setModelMatrix(modelMatrix);

Drawing a colored cube

The driver's drawQuad function takes a CQuadColor with four 3D vertices and four colors. To draw a cube, we draw its six faces:

static void drawFace(NL3D::UDriver *driver, NL3D::UMaterial &mat,
	const NLMISC::CVector &v0, const NLMISC::CVector &v1,
	const NLMISC::CVector &v2, const NLMISC::CVector &v3,
	NLMISC::CRGBA color)
{
	NL3D::CQuadColor quad;
	quad.V0 = v0; quad.V1 = v1; quad.V2 = v2; quad.V3 = v3;
	quad.Color0 = quad.Color1 = quad.Color2 = quad.Color3 = color;
	driver->drawQuad(quad, mat);
}

static void drawCube(NL3D::UDriver *driver, NL3D::UMaterial &mat,
	const NLMISC::CMatrix &transform, float s)
{
	// 8 vertices of a cube (X right, Y forward, Z up)
	NLMISC::CVector v[8] = {
		transform * NLMISC::CVector(-s, -s, -s),
		transform * NLMISC::CVector( s, -s, -s),
		transform * NLMISC::CVector( s,  s, -s),
		transform * NLMISC::CVector(-s,  s, -s),
		transform * NLMISC::CVector(-s, -s,  s),
		transform * NLMISC::CVector( s, -s,  s),
		transform * NLMISC::CVector( s,  s,  s),
		transform * NLMISC::CVector(-s,  s,  s),
	};

	drawFace(driver, mat, v[3], v[2], v[1], v[0], NLMISC::CRGBA(200,  50,  50)); // bottom red
	drawFace(driver, mat, v[4], v[5], v[6], v[7], NLMISC::CRGBA( 50, 200,  50)); // top green
	drawFace(driver, mat, v[0], v[1], v[5], v[4], NLMISC::CRGBA( 50,  50, 200)); // back blue
	drawFace(driver, mat, v[2], v[3], v[7], v[6], NLMISC::CRGBA(200, 200,  50)); // front yellow
	drawFace(driver, mat, v[3], v[0], v[4], v[7], NLMISC::CRGBA(200,  50, 200)); // left magenta
	drawFace(driver, mat, v[1], v[2], v[6], v[5], NLMISC::CRGBA( 50, 200, 200)); // right cyan
}

Call it in the render loop with a spinning transform:

	NLMISC::CMatrix cubeTransform;
	cubeTransform.identity();
	cubeTransform.setPos(NLMISC::CVector(0.f, 0.f, 1.f));
	cubeTransform.rotateZ(m_CubeAngle);
	cubeTransform.rotateX(m_CubeAngle * 0.7f);

	drawCube(m_Driver, m_CubeMat, cubeTransform, 0.8f);

Delta-time animation

Never tie animation speed to the frame rate. Use CTime::getPerformanceTime() to measure real elapsed time:

#include <nel/misc/time_nl.h>

Add timing state to the class:

	double m_LastTime;
	float m_CubeAngle;
	float m_CamAngle;
	float m_CamDist;

Initialize in the constructor:

	m_LastTime = NLMISC::CTime::ticksToSecond(NLMISC::CTime::getPerformanceTime());
	m_CubeAngle = 0.f;
	m_CamAngle = 0.f;
	m_CamDist = 6.f;

Compute delta time at the start of each frame:

	double now = NLMISC::CTime::ticksToSecond(NLMISC::CTime::getPerformanceTime());
	float dt = float(now - m_LastTime);
	m_LastTime = now;

	m_CubeAngle += dt * 1.0f;
	m_CamAngle += dt * 0.3f;

Keyboard input

To handle continuous key state (held keys), listen for both EventKeyDownId and EventKeyUpId and track the state in booleans:

	m_Driver->EventServer.addListener(EventKeyDownId, this);
	m_Driver->EventServer.addListener(EventKeyUpId, this);
void CMyGame::operator()(const CEvent &event)
{
	if (event == EventCloseWindowId)
	{
		m_CloseWindow = true;
	}
	else if (event == EventKeyDownId)
	{
		CEventKeyDown &kd = (CEventKeyDown &)event;
		if (kd.Key == KeyUP) m_KeyForward = true;
		if (kd.Key == KeyDOWN) m_KeyBackward = true;
	}
	else if (event == EventKeyUpId)
	{
		CEventKeyUp &ku = (CEventKeyUp &)event;
		if (ku.Key == KeyUP) m_KeyForward = false;
		if (ku.Key == KeyDOWN) m_KeyBackward = false;
	}
}

Apply movement in the game loop:

	if (m_KeyForward) m_CamDist -= dt * 4.f;
	if (m_KeyBackward) m_CamDist += dt * 4.f;
	if (m_CamDist < 2.f) m_CamDist = 2.f;

Drawing a background grid

Without a background, it's hard to perceive depth. Draw a simple ground grid using drawLine:

	// Draw a ground grid at Z=0
	NLMISC::CRGBA gridColor(60, 60, 60);
	for (int i = -5; i <= 5; ++i)
	{
		m_Driver->drawLine(NLMISC::CVector(float(i), -5.f, 0.f),
			NLMISC::CVector(float(i), 5.f, 0.f), gridColor, m_SkyMat);
		m_Driver->drawLine(NLMISC::CVector(-5.f, float(i), 0.f),
			NLMISC::CVector(5.f, float(i), 0.f), gridColor, m_SkyMat);
	}

Complete source code

#include <nel/misc/types_nl.h>
#include <nel/misc/app_context.h>
#include <nel/misc/event_listener.h>
#include <nel/misc/debug.h>
#include <nel/misc/time_nl.h>
#include <nel/3d/u_driver.h>
#include <nel/3d/u_material.h>
#include <nel/3d/u_text_context.h>
#include <nel/3d/frustum.h>

using namespace std;
using namespace NLMISC;

// Build a view matrix from eye position, target, and up vector
static CMatrix buildViewMatrix(const CVector &eye, const CVector &target, const CVector &up)
{
	CVector jj = (target - eye).normed();
	CVector ii = (jj ^ up).normed();
	CVector kk = ii ^ jj;
	CMatrix camWorld;
	camWorld.setRot(ii, jj, kk, true);
	camWorld.setPos(eye);
	CMatrix viewMatrix = camWorld;
	viewMatrix.invert();
	return viewMatrix;
}

// Draw a colored quad face
static void drawFace(NL3D::UDriver *driver, NL3D::UMaterial &mat,
	const CVector &v0, const CVector &v1, const CVector &v2, const CVector &v3,
	CRGBA color)
{
	NL3D::CQuadColor quad;
	quad.V0 = v0; quad.V1 = v1; quad.V2 = v2; quad.V3 = v3;
	quad.Color0 = quad.Color1 = quad.Color2 = quad.Color3 = color;
	driver->drawQuad(quad, mat);
}

// Draw a colored cube centered at origin, transformed by a matrix
static void drawCube(NL3D::UDriver *driver, NL3D::UMaterial &mat,
	const CMatrix &transform, float s)
{
	CVector v[8] = {
		transform * CVector(-s, -s, -s),
		transform * CVector( s, -s, -s),
		transform * CVector( s,  s, -s),
		transform * CVector(-s,  s, -s),
		transform * CVector(-s, -s,  s),
		transform * CVector( s, -s,  s),
		transform * CVector( s,  s,  s),
		transform * CVector(-s,  s,  s),
	};
	drawFace(driver, mat, v[3], v[2], v[1], v[0], CRGBA(200,  50,  50)); // bottom
	drawFace(driver, mat, v[4], v[5], v[6], v[7], CRGBA( 50, 200,  50)); // top
	drawFace(driver, mat, v[0], v[1], v[5], v[4], CRGBA( 50,  50, 200)); // back
	drawFace(driver, mat, v[2], v[3], v[7], v[6], CRGBA(200, 200,  50)); // front
	drawFace(driver, mat, v[3], v[0], v[4], v[7], CRGBA(200,  50, 200)); // left
	drawFace(driver, mat, v[1], v[2], v[6], v[5], CRGBA( 50, 200, 200)); // right
}

class CMyGame : public IEventListener
{
public:
	CMyGame();
	~CMyGame();
	void run();
	virtual void operator()(const CEvent &event) NL_OVERRIDE;

private:
	NL3D::UDriver *m_Driver;
	NL3D::UTextContext *m_TextContext;
	NL3D::UMaterial m_CubeMat;
	NL3D::UMaterial m_SkyMat;
	bool m_CloseWindow;
	bool m_KeyForward;
	bool m_KeyBackward;
	double m_LastTime;
	float m_CubeAngle;
	float m_CamAngle;
	float m_CamDist;
};

CMyGame::CMyGame()
	: m_CloseWindow(false)
	, m_KeyForward(false)
	, m_KeyBackward(false)
	, m_TextContext(NULL)
	, m_CubeAngle(0.f)
	, m_CamAngle(0.f)
	, m_CamDist(6.f)
{
	m_Driver = NL3D::UDriver::createDriver(0, NL3D::UDriver::OpenGl3);
	if (!m_Driver)
	{
		nlerror("Failed to create driver");
		return;
	}

	m_Driver->EventServer.addListener(EventCloseWindowId, this);
	m_Driver->EventServer.addListener(EventKeyDownId, this);
	m_Driver->EventServer.addListener(EventKeyUpId, this);

	m_Driver->setDisplay(NL3D::UDriver::CMode(800, 600, 32));
	m_Driver->setWindowTitle("Spinning Cube");

	// Optional: text overlay for HUD info
	m_TextContext = m_Driver->createTextContext("n019003l.pfb");
	if (m_TextContext)
		m_TextContext->setFontSize(12);

	// Opaque material for the cube
	m_CubeMat = m_Driver->createMaterial();
	m_CubeMat.initUnlit();
	m_CubeMat.setZWrite(true);
	m_CubeMat.setZFunc(NL3D::UMaterial::lessequal);

	// Background material: no depth write
	m_SkyMat = m_Driver->createMaterial();
	m_SkyMat.initUnlit();
	m_SkyMat.setZWrite(false);
	m_SkyMat.setZFunc(NL3D::UMaterial::always);

	m_LastTime = CTime::ticksToSecond(CTime::getPerformanceTime());
}

CMyGame::~CMyGame()
{
	if (m_TextContext)
		m_Driver->deleteTextContext(m_TextContext);
	m_Driver->deleteMaterial(m_CubeMat);
	m_Driver->deleteMaterial(m_SkyMat);
	m_Driver->release();
	delete m_Driver;
}

void CMyGame::operator()(const CEvent &event)
{
	if (event == EventCloseWindowId)
	{
		m_CloseWindow = true;
	}
	else if (event == EventKeyDownId)
	{
		CEventKeyDown &kd = (CEventKeyDown &)event;
		if (kd.Key == KeyUP) m_KeyForward = true;
		if (kd.Key == KeyDOWN) m_KeyBackward = true;
	}
	else if (event == EventKeyUpId)
	{
		CEventKeyUp &ku = (CEventKeyUp &)event;
		if (ku.Key == KeyUP) m_KeyForward = false;
		if (ku.Key == KeyDOWN) m_KeyBackward = false;
	}
}

void CMyGame::run()
{
	while (m_Driver->isActive() && !m_CloseWindow)
	{
		m_Driver->EventServer.pump();

		// Delta time
		double now = CTime::ticksToSecond(CTime::getPerformanceTime());
		float dt = float(now - m_LastTime);
		m_LastTime = now;

		// Update animation
		m_CubeAngle += dt * 1.0f;
		m_CamAngle += dt * 0.3f;
		if (m_KeyForward) m_CamDist -= dt * 4.f;
		if (m_KeyBackward) m_CamDist += dt * 4.f;
		if (m_CamDist < 2.f) m_CamDist = 2.f;

		// Set up the camera
		uint32 screenW, screenH;
		m_Driver->getWindowSize(screenW, screenH);

		NL3D::CFrustum frustum;
		frustum.initPerspective(float(Pi / 3.0),
			float(screenW) / float(screenH), 0.1f, 100.f);
		m_Driver->setFrustum(frustum);

		CVector eye(cosf(m_CamAngle) * m_CamDist,
			sinf(m_CamAngle) * m_CamDist, 3.f);
		CVector target(0.f, 0.f, 1.f);
		CVector up(0.f, 0.f, 1.f);
		m_Driver->setViewMatrix(buildViewMatrix(eye, target, up));

		CMatrix modelMatrix;
		modelMatrix.identity();
		m_Driver->setModelMatrix(modelMatrix);

		// Clear and draw
		m_Driver->clearBuffers(CRGBA(30, 30, 30));

		// Ground grid
		CRGBA gridColor(60, 60, 60);
		for (int i = -5; i <= 5; ++i)
		{
			m_Driver->drawLine(CVector(float(i), -5.f, 0.f),
				CVector(float(i), 5.f, 0.f), gridColor, m_SkyMat);
			m_Driver->drawLine(CVector(-5.f, float(i), 0.f),
				CVector(5.f, float(i), 0.f), gridColor, m_SkyMat);
		}

		// Spinning cube
		CMatrix cubeTransform;
		cubeTransform.identity();
		cubeTransform.setPos(CVector(0.f, 0.f, 1.f));
		cubeTransform.rotateZ(m_CubeAngle);
		cubeTransform.rotateX(m_CubeAngle * 0.7f);
		drawCube(m_Driver, m_CubeMat, cubeTransform, 0.8f);

		// HUD text
		if (m_TextContext)
		{
			m_TextContext->setHotSpot(NL3D::UTextContext::TopLeft);
			m_TextContext->setColor(CRGBA::White);
			m_TextContext->printfAt(0.01f, 0.99f,
				"[Up/Down] Camera distance: %.1f", m_CamDist);
		}

		m_Driver->swapBuffers();
	}
}

int main(int argc, char *argv[])
{
	CApplicationContext applicationContext;

	CMyGame myGame;
	myGame.run();

	return EXIT_SUCCESS;
}

What's next

You now know how to draw 3D geometry, control a camera, and animate with delta time. In the next tutorial, you will learn how to apply textures and use alpha blending.

⚠️ **GitHub.com Fallback** ⚠️