BambooBasic's graphics are 2D — you draw lines, rectangles, images, text and quads onto a flat screen. And yet you can render a proper spinning, shaded, solid 3D object with it. This page explains how, with a complete example you can paste in and run.
There is really only one special ingredient, and once you have it the rest is just a little school maths.
Normally 2D drawing is "painter's order" — whatever you draw last sits on top. That is no good for 3D, where a far wall must be hidden by a near one no matter which order you happen to draw them in.
A depth buffer fixes that. The GPU keeps a per-pixel record of the nearest thing drawn so far, and a pixel is only painted if it is closer than what is already there. So you can draw your faces in any order at all and they still overlap correctly — the hardware sorts it out pixel by pixel.
You feed depth in one of two ways:
0.0 (nearest) to 1.0 (farthest). Ideal
for flat, screen-facing things — a sprite, a HUD panel, a billboard.Why per-corner matters for a solid object: as it spins, its own faces overlap on screen. With a single flat depth per face the buffer cannot tell which part of which face is in front, so the inside "pops through". Per-corner depth resolves it pixel by pixel, and two nice consequences fall out:
One rule for the corner depths: they must be perspective-correct. The corners are
interpolated linearly across the screen, but a real 3D face's depth is not linear in
screen space — it is projective, of the form a + b/z (the same shape as a
GPU's clip-space Z). Feed raw view-z and a flat face's depth bends along the diagonal where the
quad splits into two triangles, giving a visible "kink" where faces meet. Map view-z to depth
with depth = (far * (z - near)) / (z * (far - near)) and it stays flat — a
clean surface.
The depth buffer defaults to 0.0 and is cleared to 1.0 every
frame, so ordinary 2D code that never sets a depth behaves exactly as it always did. You only
opt in when you want 3D.
A 3D model is just a list of vertices (points in space, each an x/y/z) grouped into faces (a few vertices that form a flat polygon, plus a colour). To draw it, each vertex travels through four short steps every frame:
1. Rotate. Spin the vertex around the axes with mathSin /
mathCos (BambooBasic's trig works in degrees). Rotating about Y turns
it left/right (yaw), about X tips it up/down (pitch):
; rotate point (x,y,z) about Y then X
x1 = x * cosY + z * sinY
z1 = z * cosY - x * sinY
y2 = y * cosX - z1 * sinX
z2 = y * sinX + z1 * cosX
2. Position (view space). Move the object to where it sits in front of the viewer. The camera lives at the origin looking down +Z, so "further away" just means a bigger z. Here we push the cube out by a fixed distance:
tz = z2 + DIST ; DIST = how far in front of the camera
3. Project. Turn the 3D point into a screen pixel. Perspective is nothing more
than dividing by depth — things further away (bigger z) move less, so they look
smaller. FOCAL sets the field of view:
FOCAL = HalfHeight / mathTan(halfFOV) ; once, at startup (e.g. halfFOV = 38)
screenX = HalfWidth + FOCAL * x / tz
screenY = HalfHeight - FOCAL * y / tz ; minus: screen Y grows downward
4. Shade, then draw with per-corner depth. For each face, build its normal (the direction it faces) from a cross product of two edges, point it at the camera, and light it with a simple ambient + diffuse formula. Then give each of the four corners its own perspective-correct depth and draw the face as a filled b2dDrawQuadDepth:
b2dSetColor(r * lit, g * lit, b * lit) ; flat shade
; each corner's own depth, perspective-correct (0..1, near..far):
dA = (far * (tzA - near)) / (tzA * (far - near)) ; ...likewise dB, dC, dD
b2dDrawQuadDepth(sxA,syA,dA, sxB,syB,dB, sxC,syC,dC, sxD,syD,dD, 1) ; 1 = filled
That is the whole technique. Everything else is choosing nicer models, colours and lighting.
Paste this into a .bam next to BBR_INCLUDE.bam and run it. It
is about as small as a real 3D-in-2D program gets: eight vertices, six coloured faces,
lit and depth-sorted, spinning. Press ESC to quit.
Import "BBRuntimeLinux.decls"
Include "BBR_INCLUDE.bam"
Const SW:Int = 640
Const SH:Int = 480
Const HW:Int = 320
Const HH:Int = 240
Const DIST:Double = 4.0 ; how far the cube sits in front of the camera
Const NEARZ:Double = 1.0 ; near plane for the depth mapping
Const FARZ:Double = 20.0 ; far plane (NEARZ/FARZ avoid the windows.h NEAR/FAR macros)
Const AMB:Double = 0.30 ; ambient light
Const DIF:Double = 0.85 ; diffuse strength
Global FOCAL:Double
; --- cube geometry (object space) ---
Global vx:Double[8]
Global vy:Double[8]
Global vz:Double[8]
Global tvx:Double[8] ; transformed into view space
Global tvy:Double[8]
Global tvz:Double[8]
Global spx:Int[8] ; projected to the screen
Global spy:Int[8]
Global fidx:Int[24] ; 6 faces x 4 vertex indices
Global fcr:Int[6]
Global fcg:Int[6]
Global fcb:Int[6]
; light direction (points TOWARD the light), normalised in Setup
Global lx:Double = -0.4
Global ly:Double = 0.6
Global lz:Double = -0.6
Global angY:Double = 0.0
Global angX:Double = 0.0
Global running:Int = True
Function SetVert(i:Int, x:Double, y:Double, z:Double)
vx[i] = x
vy[i] = y
vz[i] = z
EndFunction
Function SetFace(n:Int, a:Int, b:Int, c:Int, d:Int, r:Int, g:Int, bl:Int)
fidx[n * 4 + 0] = a
fidx[n * 4 + 1] = b
fidx[n * 4 + 2] = c
fidx[n * 4 + 3] = d
fcr[n] = r
fcg[n] = g
fcb[n] = bl
EndFunction
Function Setup()
b2dGraphics(SW, SH, BBR_WINDOW_MODE_WT)
sysSetWindowTitle("3D in 2D - spinning cube")
; focal length from a 38-degree half field-of-view
FOCAL = ToDouble(HH) / mathTan(38.0)
; normalise the light direction
Local ll:Double = mathSqr(lx * lx + ly * ly + lz * lz)
lx = lx / ll
ly = ly / ll
lz = lz / ll
; the 8 corners of a unit cube
SetVert(0, -1, -1, -1)
SetVert(1, 1, -1, -1)
SetVert(2, 1, 1, -1)
SetVert(3, -1, 1, -1)
SetVert(4, -1, -1, 1)
SetVert(5, 1, -1, 1)
SetVert(6, 1, 1, 1)
SetVert(7, -1, 1, 1)
; the 6 faces (quads) - the winding does not matter, the depth buffer sorts it
SetFace(0, 0, 1, 2, 3, 220, 60, 60) ; front red
SetFace(1, 5, 4, 7, 6, 60, 220, 60) ; back green
SetFace(2, 4, 0, 3, 7, 60, 90, 230) ; left blue
SetFace(3, 1, 5, 6, 2, 230, 200, 50) ; right yellow
SetFace(4, 3, 2, 6, 7, 230, 130, 40) ; top orange
SetFace(5, 4, 5, 1, 0, 180, 60, 210) ; bottom purple
EndFunction
Function DrawCube()
Local sinY:Double = mathSin(angY)
Local cosY:Double = mathCos(angY)
Local sinX:Double = mathSin(angX)
Local cosX:Double = mathCos(angX)
; --- transform + project every vertex ---
Local i:Int
For i = 0 To 7
Local x:Double = vx[i]
Local y:Double = vy[i]
Local z:Double = vz[i]
Local x1:Double = x * cosY + z * sinY ; rotate about Y
Local z1:Double = z * cosY - x * sinY
Local y2:Double = y * cosX - z1 * sinX ; rotate about X
Local z2:Double = y * sinX + z1 * cosX
tvx[i] = x1
tvy[i] = y2
tvz[i] = z2 + DIST ; push in front of the camera
spx[i] = HW + ToInt(FOCAL * tvx[i] / tvz[i]) ; perspective divide
spy[i] = HH - ToInt(FOCAL * tvy[i] / tvz[i])
Next
; --- draw each face ---
Local f:Int
For f = 0 To 5
Local a:Int = fidx[f * 4 + 0]
Local b:Int = fidx[f * 4 + 1]
Local c:Int = fidx[f * 4 + 2]
Local d:Int = fidx[f * 4 + 3]
; face normal from two edges (cross product)
Local e1x:Double = tvx[b] - tvx[a]
Local e1y:Double = tvy[b] - tvy[a]
Local e1z:Double = tvz[b] - tvz[a]
Local e2x:Double = tvx[c] - tvx[a]
Local e2y:Double = tvy[c] - tvy[a]
Local e2z:Double = tvz[c] - tvz[a]
Local nx:Double = e1y * e2z - e1z * e2y
Local ny:Double = e1z * e2x - e1x * e2z
Local nz:Double = e1x * e2y - e1y * e2x
Local nl:Double = mathSqr(nx * nx + ny * ny + nz * nz)
If nl < 0.0001 Then nl = 0.0001
nx = nx / nl
ny = ny / nl
nz = nz / nl
; point the normal at the camera (origin), so winding never matters
If nx * tvx[a] + ny * tvy[a] + nz * tvz[a] > 0.0 Then
nx = 0.0 - nx
ny = 0.0 - ny
nz = 0.0 - nz
EndIf
; flat shade: ambient + diffuse * (normal . light)
Local diff:Double = nx * lx + ny * ly + nz * lz
If diff < 0.0 Then diff = 0.0
Local lit:Double = AMB + DIF * diff
If lit > 1.0 Then lit = 1.0
b2dSetColor(ToInt(ToDouble(fcr[f]) * lit), ToInt(ToDouble(fcg[f]) * lit), ToInt(ToDouble(fcb[f]) * lit))
; each corner's OWN depth, perspective-correct (a + b/z) so the face stays a
; flat plane per pixel - draw with b2dDrawQuadDepth and the buffer sorts it
Local dA:Double = (FARZ * (tvz[a] - NEARZ)) / (tvz[a] * (FARZ - NEARZ))
Local dB:Double = (FARZ * (tvz[b] - NEARZ)) / (tvz[b] * (FARZ - NEARZ))
Local dC:Double = (FARZ * (tvz[c] - NEARZ)) / (tvz[c] * (FARZ - NEARZ))
Local dD:Double = (FARZ * (tvz[d] - NEARZ)) / (tvz[d] * (FARZ - NEARZ))
b2dDrawQuadDepth(spx[a], spy[a], dA, spx[b], spy[b], dB, spx[c], spy[c], dC, spx[d], spy[d], dD, 1)
Next
EndFunction
Function Main()
Setup()
While running
sysUpdateEvents()
If inpIsKeyDown(VKEY_ESCAPE) Then running = False
angY = angY + 0.8
angX = angX + 0.5
b2dSetClsColor(15, 20, 35)
b2dCls()
DrawCube()
b2dFlip()
Wend
b2dEnd()
Return False
EndFunction
That cube is the whole idea in miniature. To build it up:
DIST, subtract the camera's position and rotation before projecting.z >= near before dividing.b2dDrawLine (also depth-aware) gives
you grids and edges (b2dSetMSAA, mentioned in the Windows documentation, is not in this edition, so polygon edges are not smoothed.)Two limitations to know before building something large on this, both to do with how the
depth buffer is wired up: primitives drawn into a render target
(b2dCreateRenderTarget) are not depth-tested, because a render target carries no
depth buffer — and neither is anything drawn while a screen shader is applied,
because the frame is composited through an offscreen target. If you switch a CRT effect on and
your model goes flat, that is why.
The StarBam sample (a Star Fox-style rail shooter) is exactly this technique scaled up — and because it is pure 2D, the identical renderer runs unchanged on every target this edition supports. That is the real payoff: one 2D drawing API, real 3D, every target.