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 "BBRuntime64.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 smooths the polygon edges.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 on desktop and in the browser. That is the real payoff: one 2D drawing API, real 3D, every target.