BambooBasic Logo


3D in 2D

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.



The one idea: a depth buffer

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:

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.



The pipeline: from a 3D point to a screen pixel

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.



The commands you need

A complete example: a spinning, shaded cube

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

Where to go from here

That cube is the whole idea in miniature. To build it up:

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.



BambooBasic — 3D in 2D