MS-DOS Assembly 3D Graphics

3DOMS, the 3-Dimensional Object Management System, is a 3D engine for MS-DOS featuring flat-shaded triangles and a scene hierarchy, with rendering via camera distance object sorting.

Objects are defined as abstract forms, and these are then instantiated in a scene graph using a position and an orientation.

Numbers are fixed point, with 8 bits after the point. The corners of faces are 16-bit words in 8.8 format, in each shape's own local co-ordinates, and the positions of objects and groups are 32-bit, in 24.8 format, so they can be placed further out. Angles are bytes, with 256 steps to a turn.

The tester

The demo at the top is the tester, TESTER.ASM. It makes four boxes which share one box form, and turns them until Esc is pressed.

Four objects sharing one form
Four objects sharing one form

The 3D system is kept in its own file, and the tester includes it at the top:

.MODEL small
.STACK 1024
.386

JUMPS



;-------- The 3D system.

.CODE

INCLUDE 3DOMS012.INC
The top of TESTER.ASM, which brings in the 3D system

The code to make this scene:

    ;  Make a group in the universe, to turn the whole scene.
  mov ah, 1Ch
  mov si, offset _groupdata
  mov es, _universe
  int 3Dh
  jc tester_error
  mov _group, ax

    ;  Make the box form, with one shape in it.
  mov ah, 20h
  mov bx, offset _centre
  int 3Dh
  jc tester_error
  mov _form, ax
  mov es, ax
  mov ah, 14h
  mov si, offset _centre
  int 3Dh
  jc tester_error

    ;  Add the 12 faces to the shape.
  mov es, ax
  mov si, offset _faces
  mov cx, 12
tester_face:
  mov ah, 10h
  int 3Dh
  jc tester_error
  add si, 20
  loop tester_face

    ;  Make the four objects in the group, and give each one the box form.
  mov si, offset _objects
  mov cx, 4
tester_object:
  mov ah, 18h
  mov es, _group
  int 3Dh
  jc tester_error
  mov es, ax
  mov ah, 1Bh
  mov bx, _form
  int 3Dh
  jc tester_error
  add si, 22
  loop tester_object

    ;  Wait for a key, then switch to 320x200 in 256 colours.
  mov si, offset _msg_key
  call print
  mov ah, 07h
  int 21h
  mov ax, 0013h
  int 10h

tester_loop:
    ;  Clear the buffer to stripes of colours 1 and 0.
  mov es, _buffer
  xor di, di
  mov cx, 16000
  mov eax, 00010001h
  cld
  rep stosd

    ;  Turn the group a little more each frame.
  push ds
  mov ds, _group
  add byte ptr ds: [G_RX], 1
  add byte ptr ds: [G_RY], 2
  add byte ptr ds: [G_RZ], 4
  pop ds

    ;  Render the universe into the buffer.
  push ds
  mov ah, 80h
  mov ds, _universe
  int 3Dh
  pop ds
  jc tester_error

    ;  Wait for the vertical retrace, then copy the buffer to the screen.
  mov dx, 03DAh
tester_retrace:
  in al, dx
  test al, 08h
  jz tester_retrace
  push ds
  mov ds, _buffer
  xor si, si
  mov ax, 0A000h
  mov es, ax
  xor di, di
  mov cx, 16000
  rep movsd
  pop ds

    ;  Carry on until Esc is let go.
  in al, 60h
  cmp al, 81h
  jne tester_loop

    ;  Back to text mode, and log off.
  mov ax, 0003h
  int 10h
  call logoff_3doms
  ret
Making the group, the box form and the four objects, then turning and rendering them until Esc is let go

All four boxes are in one group, so turning the group turns the whole scene, and the group's position puts it in front of the viewer. The boxes' own positions spread them out within the group. The scene's data:

  ;  The group: centre, position, rotations and rotation order.  It sits in
  ; front of the viewer and turns the whole scene.
_groupdata	dw 0, 0, 0
		dd 0, 0, -28000
		db 20h, 00h, 00h, RORDER_ZYX

  ;  The four boxes: centre, position, rotations and rotation order.
_objects	dw 0, 0, 0
		dd -8192, -8192, -3072
		db 00h, 00h, 00h, RORDER_XYZ
		...

  ;  The box: 12 faces, two on each side, each in a different grey from the
  ; standard palette.
_faces		dw -4096, 4096, -8192, 4096, -4096, -8192, 4096, 4096, -8192
		db 1Fh, 0
		dw 4096, -4096, -8192, -4096, 4096, -8192, -4096, -4096, -8192
		db 1Eh, 0
		...
The group, the first of the four boxes and the first two of the box's 12 faces (trimmed)

The box is twice as long as it is wide, and each of its faces is a different grey, colours 14h to 1Fh of the standard palette. Each frame the buffer is cleared to stripes of colours 1 and 0, which makes the blue background, and the finished frame is copied to the screen after the vertical retrace. Port 60h holds the scan code of the last key that went down or up, so the tester just watches it for Esc being let go.

The tester was written with AI assistance for this page, to build the same scene against the version of 3DOMS shown here.

The heap

Everything in a scene lives on a heap of 32-byte cells, which 3DOMS asks DOS for when you log on. It asks for the most cells the program wants, and if DOS doesn't have that many, for whatever is left, as long as that's at least the minimum. Then it marks every cell free and takes the first one for the universe.

A cell is two paragraphs, so it can be addressed by a segment on its own, with every field at a fixed offset from 0. Structures are passed around as segments in registers, and each one starts with the same few fields:

_MAGIC		equ 0000h	;  (B) Magic - indicates type of structure.
_PARENT		equ 0001h	;  (W) Segment of parent structure.
_NUM		equ 0003h	;  (B) Number of child structures.
_PTR		equ 0004h	;  (W) Segment of first child in linked list.
_PREV		equ 0006h	;  (W) Segment of previous structure in list.
_NEXT		equ 0008h	;  (W) Segment of next structure in list.
_SSDATA		equ 000Ah	;  (.) Start of structure-specific data.
The fields every structure starts with: what it is, its parent, its children and its place in its parent's list

The cells form a tree, with the universe at the top. Each structure points down to its first child with _PTR, and the children are a doubly linked list of siblings, joined by _PREV and _NEXT, with 0 at each end. Every child points back up to its parent with _PARENT, and the parent counts its children in _NUM. Forms are the one exception: they hang off no parent, and objects point at them instead.

That leaves 22 bytes for the rest of each structure, which is laid out in the scene format. The first byte, the magic, says what's in the cell: F, S, M, O, G or U for a face, shape, form, object, group or universe, and 0 for a free cell.

Allocating a cell is a walk from the start of the heap to the first magic of 0, which is function 40h, and all the create functions use it. Creating a child also checks the parent's magic and that it has fewer than 127 children, then puts the new cell at the front of the parent's list and adds one to the parent's count.

Freeing a cell sets its magic back to 0. The delete functions check the magics of the cell and of its neighbours in the list, then unlink it, moving the parent's pointer on if it was the first child. A structure's children aren't freed with it, so they have to be deleted first. Function 00h says how big the heap is and how many cells are free.

The magics catch a lot of mistakes. Pass a shape where a form is expected and you get ERROR_BADPARENT back instead of a corrupted heap. And while I was testing, every new cell was filled with FFh, so anything that wasn't set up properly would show:

    ;  TEMPORARY - Fill cell with 0FFh's (apart from magic).
  push es
  push di
  push cx
  push ax
  cld
  mov es, ax
  mov di, 1
  mov al, 0FFh
  mov cx, 31
  rep stosb
  ...
Filling each new cell with FFh while testing (trimmed)

Scene format

A scene is a tree of structures, one to a cell. The documentation at the top of the file draws it like this:

  (POINT) <- Face <- Shape <- FORM <- Object <- Group <- UNIVERSE
The chain of structures, from the documentation, with points still to come

Faces, shapes and forms describe what something looks like, and objects and groups say where it is. The documentation explains it with planes:

  Shapes and faces are constants - if you want to have more than one plane
flying around, as long as all the planes look the same, they can all use the
same shapes and faces.  A form is basically a descriptor for a linked list of
shapes.  When you create a new object, you specify it's form, which will be
used when the object is drawn.
Forms, from the documentation

That's how the tester draws four boxes from one box form. Here are the structures from the top of the tree down.

Universe

There's only one universe, the heap's first cell, made when you log on. It holds the list of groups, and it's where rendering starts.

U_MAGIC		equ 0000h	; (B) Magic byte.
U_SPARE00	equ 0001h	; (W)
U_NUM		equ 0003h	; (B) Number of groups.
U_PTR		equ 0004h	; (W) Segment of first group in linked list.
The universe's fields

Group

A group holds a list of objects, with a position, three angles and a rotation order of its own, so everything in it moves and turns together. As the documentation puts it, "A group may be a plane object and some wheel and flap objects, as the wheels and flaps are not fixed in position on the plane."

G_MAGIC         equ 0000h	;  (B) Magic byte.
G_PARENT        equ 0001h      	;  (W) Segment of parent universe.
G_NUM           equ 0003h	;  (B) Number of objects.
G_PTR           equ 0004h	;  (W) Segment of first object in linked list.
G_PREV          equ 0006h       ;  (W) Segment of previous group, or 0.
G_NEXT          equ 0008h       ;  (W) Segment of next group, or 0 if none.
G_DATA          equ 000Ah       ;  (.)
G_CX            equ 000Ah       ;  (W) Co-ordinates of center of group.
G_CY            equ 000Ch
G_CZ            equ 000Eh
G_X             equ 0010h       ;  (D) Transformations.
G_Y             equ 0014h
G_Z             equ 0018h
G_RX            equ 001Ch       ;  (B) Rotation about the axes.
G_RY            equ 001Dh
G_RZ            equ 001Eh
G_RORDER        equ 001Fh       ;  (B) Order of rotations.
A group's fields: its place in the universe, its objects, and where it is and which way it faces

Groups are made, deleted and moved with functions 1Ch, 1Dh and 1Eh.

Object

An object puts a form into the scene. Where other structures point at their first child, an object points at its form, and it has a position, three angles and a rotation order, laid out the same as a group's. How the angles and the order are used is in rotation.

O_MAGIC         equ 0000h       ;  (B) Magic byte.
O_PARENT        equ 0001h       ;  (W) Segment of parent group.
O_SPARE00       equ 0003h       ;  (B)
O_PTR           equ 0004h       ;  (W) Segment of form.
O_PREV          equ 0006h       ;  (W) Segment of previous object, or 0.
O_NEXT          equ 0008h       ;  (W) Segment of next object, or 0 if none.
O_DATA          equ 000Ah       ;  (.)
O_CX            equ 000Ah       ;  (W) Co-ordinates of center of object.
O_CY            equ 000Ch
O_CZ            equ 000Eh
O_X             equ 0010h       ;  (D) Transformations.
O_Y             equ 0014h
O_Z             equ 0018h
O_RX            equ 001Ch       ;  (B) Rotation about the axes.
O_RY            equ 001Dh
O_RZ            equ 001Eh
O_RORDER        equ 001Fh       ;  (B) Order of rotations.
An object's fields: its place in its group, its form, and where it is and which way it faces

Objects are made, deleted and moved with functions 18h, 19h and 1Ah. A new object has no form until function 1Bh gives it one.

Form

A form is a list of shapes. Forms don't belong to anything: they sit on the heap on their own, and objects point at them. Where other structures keep their parent, a form has room for a count of the objects using it, though nothing keeps that count yet.

M_MAGIC         equ 0000h       ;  (B) Magic byte.
M_NUMOBJ        equ 0001h       ;  (W) Number of objects which use this form.
M_NUM           equ 0003h       ;  (B) Number of shapes in form.
M_PTR           equ 0004h       ;  (W) Segment of first shape in linked list.
M_PREV          equ 0006h       ;  (W) Segment of previous form in list.
M_NEXT          equ 0008h       ;  (W) Segment of next form in linked list.
M_SPARE01       equ 000Ah       ;  (W)
M_SPARE02       equ 000Ch       ;  (W)
M_DATA          equ 000Eh       ;  (.)
M_CX            equ 000Eh       ;  (W) Co-ordinates center of form.
M_CY            equ 0010h
M_CZ            equ 0012h
A form's fields: its shapes and its centre

Forms are made and deleted with functions 20h and 21h.

Shape

A shape is a list of faces with a centre. It has to be convex, so that leaving out the faces that face away is all it takes to draw it properly. Anything that isn't convex is split into several shapes, and the centres are there so the shapes can be drawn from the furthest forward.

S_MAGIC         equ 0000h       ;  (B) Magic byte.
S_PARENT        equ 0001h       ;  (W) Segment of parent form.
S_NUM           equ 0003h       ;  (B) Number of faces.
S_PTR           equ 0004h       ;  (W) Segment of first face in linked list.
S_PREV          equ 0006h       ;  (W) Segment of previous shape, or 0 if none.
S_NEXT          equ 0008h       ;  (W) Segment of next shape, or 0 if none.
S_DATA          equ 000Ah       ;  (.) Start of data - S_CX.
S_CX            equ 000Ah       ;  (W) Co-ordinates of center of shape.
S_CY            equ 000Ch
S_CZ            equ 000Eh
A shape's fields: its place in its form, its faces and its centre

Shapes are made and deleted with functions 14h and 15h.

Face

Faces are always triangles, "To avoid any programming difficulties on my part and to make rendering as fast as possible". A face is three corners, in the shape's own co-ordinates, and a colour, which only just fits in the cell. The corners go clockwise on the screen when the face is towards you.

F_MAGIC         equ 0000h	;  (B) Magic byte.
F_PARENT        equ 0001h       ;  (W) Segment of parent shape.
F_SPARE00       equ 0003h       ;  (B) There are no child structures.
F_SPARE01	equ 0004h	;  (W) There are no child structures.
F_PREV          equ 0006h       ;  (W) Segment of previous face, or 0 if none.
F_NEXT          equ 0008h       ;  (W) Segment of next face, or 0 if none.
F_DATA          equ 000Ah       ;  (.) Start of face description data.
F_X1            equ 000Ah       ;  (W) Co-ordinates of corners of face.
F_Y1            equ 000Ch
F_Z1            equ 000Eh
F_X2            equ 0010h
F_Y2            equ 0012h
F_Z2            equ 0014h
F_X3            equ 0016h
F_Y3            equ 0018h
F_Z3            equ 001Ah
F_COL           equ 001Ch       ;  (B) Colour of face (palette index).
A face's fields: its place in its shape, its three corners and its colour

Faces are made and deleted with functions 10h and 11h.

Maths

There are no maths routines apart from the rotations. Each operation is written out in place, wherever it's needed.

Number formats

Every number with a fraction has 8 bits after the point, so 100h is 1.0. What changes is how many bits there are in front of the point, depending on where the number is kept:

ValueKept asFormat
Face corners16-bit words8.8, from -128 to just under 128
Centres of shapes, forms, objects and groups16-bit words8.8
Positions of objects and groups32-bit double words24.8
Camera distances, scr2eye and scr2org32-bit double words24.8, so 8000h is 128.0
Sines and cosines32-bit double words8.8 values, from -100h to 100h
Corners being drawn, in the work area32-bit double words24.8
Products, inside a multiply or a divide64 bits, in EDX:EAX16 bits after the point, for one instruction or two
Screen co-ordinates16-bit wordsWhole pixels, nothing after the point
AnglesBytes256 steps to a turn
Where each kind of number is kept, and how many of its bits are after the point

A corner goes through them in order as it's drawn:

  1. It's read from the face as an 8.8 word and widened to a 24.8 double word. The 8 bits after the point stay where they are, and the 16 bits in front are new.
  2. It's rotated. Each multiply by an 8.8 sine or cosine makes a 64-bit product with 16 bits after the point, which is shifted straight back to 24.8.
  3. Positions are added, 24.8 to 24.8.
  4. It's projected, which is a multiply and a divide. The multiply by the eye distance makes another 64-bit product with 16 bits after the point, and dividing by the 24.8 depth brings it back to 24.8. A shift right by 8 then drops the fraction, and the low 16 bits are kept as a screen co-ordinate in whole pixels.
  5. From there on, the cross product, the screen addresses and the edges of each triangle are plain 16-bit whole numbers.

So nothing is ever kept with 16 bits after the point. The extra bits only exist in EDX:EAX, between a multiply and the shift or divide that follows it.

Widening to 32 bits

A face's corners are 8.8 words, but the positions, sines and camera distances they're combined with are all double words. So before any work is done on a face, each co-ordinate is sign-extended with CWDE, which turns the 8.8 word in AX into a 24.8 double word in EAX. The 8 bits after the point stay where they are, and the sign is copied into the 16 new bits in front. It's then stored in the work area, where there's room for it to grow:

  mov ax, ds: [F_X1]
  cwde
  mov _int3D_drawface_xyz[INT3D_3D_X1], eax
Turning a corner's x from an 8.8 word into a 24.8 double word in the work area

Adding

Fixed-point numbers with the same number of bits after the point add and subtract like plain integers. By now the corners and the positions are both 24.8, so moving something is just an ADD: an object's position is added to each of its three corners in turn, and then the group's position the same way:

  mov eax, ds: [O_X]
  add _int3D_drawface_xyz[INT3D_3D_X1], eax
  add _int3D_drawface_xyz[INT3D_3D_X2], eax
  add _int3D_drawface_xyz[INT3D_3D_X3], eax
Moving the three corners of a face along x by the object's position

Multiplying

A 24.8 co-ordinate times an 8.8 sine or cosine gives a 64-bit product in EDX:EAX with 16 bits after the point. Shifting it right by 8 brings it back to 24.8. Rather than doing a 64-bit shift, mov al, dl copies the low byte of EDX over the bottom byte of EAX, which is about to be thrown away anyway, and ror eax, 8 rotates it round to the top. That leaves bits 8 to 39 of the product in EAX, which is the 24.8 answer:

  mov eax, _3doms_cos[bx]
  imul ecx
  mov al, dl
  ror eax, 8
Multiplying a 24.8 co-ordinate in ECX by an 8.8 cosine, and shifting the product back to 24.8

Dividing

Division is used for perspective. The 24.8 co-ordinate is multiplied by the 24.8 eye distance, which leaves a 64-bit product in EDX:EAX with 16 bits after the point. That's just what IDIV divides, so nothing is lost in between, however big the product gets. Dividing by the 24.8 depth takes 8 of those bits off again, leaving a 24.8 answer in EAX, and the shift right by 8 drops the rest, leaving whole pixels:

  mov eax, dword ptr _int3D_drawface_xyz[INT3D_3D_X1]
  imul dword ptr _3doms_camera_scr2eye
  ...
  idiv ecx
  shr eax, 8
Multiplying x by the eye distance, dividing by the depth, and dropping the 8 bits after the point (trimmed)

The shift is SHR rather than SAR, so a negative answer gets the wrong top bits, but only AX is kept, as a 16-bit screen co-ordinate, and AX comes out right either way. The whole calculation is in projection.

Screen addresses

Screen co-ordinates are 16-bit whole pixels, with nothing after the point, so the rest is plain integer arithmetic. A pixel's offset in the 320x200 buffer is y times 320 plus x. The triangle filler does one MUL for the row a half of the triangle starts on, adds the x positions, and from then on just adds 320 for each row:

  mov ax, 320
  mul word ptr INT3D_2D_Y1[si]
  mov bx, ax
  add bx, INT3D_2D_X2[si]
  add ax, INT3D_2D_X3[si]
  mov di, ax
Working out where the top row of a triangle starts in the buffer

Interpolating

On each row, the triangle filler works out how far along an edge it is. That's the edge's change in x, times the rows left to go, divided by the edge's height, all as 16-bit whole numbers. It costs a multiply and a divide for each edge on every row, where stepping by a fixed-point slope would only need an add:

  mov ax, _int3D_triangle_x1x2
  imul cx
  idiv _int3D_triangle_y2y1
  add bx, ax
Finding where the edge from the first corner to the second crosses this row

Cross product

Whether a face is towards you or away comes from the cross product of two of its edges on the screen. Only the z part is needed, which is two multiplies and a subtract, and only its sign matters. It's done on the 16-bit screen co-ordinates with 16-bit IMULs, keeping the low word of each product:

  mov cx, _int3D_drawface_scrxy[INT3D_2D_X3]
  sub cx, _int3D_drawface_scrxy[INT3D_2D_X2]
  mov ax, _int3D_drawface_scrxy[INT3D_2D_Y2]
  sub ax, _int3D_drawface_scrxy[INT3D_2D_Y1]
  imul cx
  mov bx, ax
  ...
The first of the cross product's two multiplies (trimmed)

How it's used is in faces that face away.

Rotation

Each rotation is the usual pair of multiplies and an add or subtract for each co-ordinate, each multiply done as above. The angle is a byte, so it's turned into an offset in the sine table by shifting it left by 2, since each entry is 4 bytes:

int3D_rotate_x:
  push bx
    ;  Calculate offset in sine table.
  xor bx, bx
  mov bl, al
  shl bx, 2

    ;  NY = Y*cos(AL)-Z*sin(AL)
  mov ecx, cs: INT3D_3D_Y1[si]
  mov eax, _3doms_cos[bx]
  imul ecx
  mov al, dl
  ror eax, 8
  mov cs: INT3D_3D_Y1[si], eax
  mov eax, _3doms_sin[bx]
  imul dword ptr cs: INT3D_3D_Z1[si]
  mov al, dl
  ror eax, 8
  sub cs: INT3D_3D_Y1[si], eax
  ...
Rotating about the x axis: the new y is y cos a - z sin a (trimmed)

The y and z rotations are copies of it with the axes changed, still with its comments about NY and NZ.

Objects and groups each turn about all three axes, in the order their rotation order byte gives. It's the bottom three bits: bit 0 means start with y, bit 1 means start with z, and bit 2 swaps the other two round, so RORDER_ZYX is 110b.

Sines and cosines

Sines are 8.8 values, where 100h means 1, but each one is stored as a 32-bit double word, so it can be multiplied straight against a 24.8 co-ordinate in a 32-bit register. The cosine table doesn't need space of its own: a cosine is the sine 90 degrees on, so the cosine table starts 64 entries into the sine table, which carries on for another 256:

_3doms_sin	dd 000000000h, 000000006h, 00000000Ch, 000000012h  ;  0°
		dd 000000019h, 00000001Fh, 000000025h, 00000002Bh
		dd 000000032h, 000000038h, 00000003Eh, 000000044h
		...
_3doms_cos	dd 000000100h, 000000100h, 0000000FFh, 0000000FFh  ;  90°
		dd 0000000FFh, 0000000FEh, 0000000FDh, 0000000FCh
		...
The start of the sine table and, 64 entries in, the cosine table (trimmed)

Rendering

Function 80h goes through every group, every object in each group, every shape in each object's form, and every face in each shape. Each face is worked out from scratch:

  1. Its three corners are copied out and widened from 8.8 words to 24.8 double words.
  2. They're rotated by the object's angles, in the object's rotation order, and moved by the object's position.
  3. Then they're rotated and moved again by the group's.
  4. Each corner is projected onto the screen.
  5. If the triangle on the screen is facing away, it's left out.
  6. Otherwise it's filled with the face's colour.

Projection

Projecting is one formula, with the eye scr2eye units behind the screen and the screen scr2org units in front of the origin:

    ;  Calculate screen co-ordinates.
    ;  SX1 = (scr2eye*X1) / (scr2eye+scr2org+Z1).
  mov eax, dword ptr _int3D_drawface_xyz[INT3D_3D_X1]
  imul dword ptr _3doms_camera_scr2eye
  mov ecx, _3doms_camera_scr2eye
  add ecx, _3doms_camera_scr2org
  add ecx, _int3D_drawface_xyz[INT3D_3D_Z1]
  idiv ecx
  shr eax, 8
  add ax, 159
  mov _int3D_drawface_scrxy[INT3D_2D_X1], ax
Projecting the first corner's x onto the screen, centred on column 159

Both distances are 8000h, which is 128.0, so at a z of 0 the divide halves x, and the shift drops the 8 bits after the point: 1.0 is half a pixel at that distance. Adding 159 centres it, and AX is stored as a 16-bit screen co-ordinate. The same is written out six times, for the x and y of each corner.

The centre of the screen is fixed at 159, 99, and rows are 320 bytes everywhere, which is why one of the aims in the comments is to make the drawing routines use the screen width.

Faces that face away

These are found on the screen, after projecting. The cross product of two edges says which way round the corners go, and a face whose corners go round the wrong way is facing away from you:

    ;  Calculate cross vector product - If this is -ive then the shape is
    ; facing away - don't draw it.
  mov cx, _int3D_drawface_scrxy[INT3D_2D_X3]
  sub cx, _int3D_drawface_scrxy[INT3D_2D_X2]
  mov ax, _int3D_drawface_scrxy[INT3D_2D_Y2]
  sub ax, _int3D_drawface_scrxy[INT3D_2D_Y1]
  imul cx
  mov bx, ax
  mov cx, _int3D_drawface_scrxy[INT3D_2D_X2]
  sub cx, _int3D_drawface_scrxy[INT3D_2D_X1]
  mov ax, _int3D_drawface_scrxy[INT3D_2D_Y3]
  sub ax, _int3D_drawface_scrxy[INT3D_2D_Y2]
  imul cx
  sub ax, bx
  jle int3D_drawface_nodraw
Leaving out faces whose corners go round the wrong way on the screen

This is why shapes have to be convex. With the far side of a convex shape left out, nothing that's left can overlap, so the faces can be drawn in any order.

Filling triangles

The triangle routine was the hard part. The comments say "It took some doing!", and its documentation promises that it "Draws a filled triangle. Nearly always."

It sorts the three corners by y, with three compare-and-swaps, then fills the top half, from the first corner down to the middle one, and the bottom half, from the middle corner down to the last. On each row it works out where the two edges are with a multiply and a divide, and fills the row between them. There's no clipping, so a face that goes off the side of the screen wraps round onto the other side.

The rows are filled with STOS, four pixels at a time where it can. A row starts with one byte if its length is odd, then one word if what's left isn't a multiple of four, then double words. The commented-out rep stosb at the top is the simple version it replaced:

int3D_line:
  jcxz int3D_line_noline

;  rep stosb		;  Slow but safe while testing rendering functions.
;  ret

  shr cx, 1
  jnc int3D_line_nobyte
  stosb
int3D_line_nobyte:
  jcxz int3D_line_noline
  mov ah, al
  shr cx, 1
  jnc int3D_line_noword
  stosw
int3D_line_noword:
  jcxz int3D_line_noline
  push ax
  rol eax, 16
  pop ax
  rep stosd
int3D_line_noline:
  ret
Filling one row of a triangle: a byte, a word, then four pixels at a time

The comments call the rendering "far too slow", and the code shows why. Every face is a software interrupt. Every corner is rotated and projected again for every face it's part of. And every corner and every row of every triangle has a divide. The plan for points in the documentation is the answer to the second of those: "a point shared between many faces will have all it's calculations done once per shape, not once per face."

The interface

Everything is done through a software interrupt, INT 3Dh, once the program has logged on. Logging on is a normal call, with the smallest and largest heap the program will accept, in 32-byte cells:

    ;  Log on, asking for a heap of between 128k and 512k.  The universe is
    ; the first cell of the heap.
  mov bx, 1000h
  mov dx, 4000h
  call logon_3doms
  jc tester_nomem
  mov _universe, ax
Logging on, asking for a heap of between 128k and 512k

Logging on returns the heap's segment in AX and its size in CX. The universe is the heap's first cell, so its segment is the heap's segment too. When you're finished, you log off again, which frees the heap and puts the old INT 3Dh vector back:

  call logoff_3doms
Logging off

In between, each function takes its number in AH. Structures are passed and returned as segments. If something goes wrong, the carry flag is set and AX holds an error code:

ErrorValueMeaning
ERROR_HEAPOUT-1There are no free cells left
ERROR_BADSHAPE, ERROR_BADFORM, ERROR_BADOBJECT, ERROR_BADGROUP, ERROR_BADUNIVERSE-3 to -7The segment isn't the structure the function wanted
ERROR_BADPARENT-8The parent isn't the right kind of structure
ERROR_BADCHILD-9The structure to delete isn't the right kind
ERROR_TOOMANY-16The parent already has 127 children
ERROR_BADLIST-32A linked list is corrupt
The error codes that come back in AX

00h - Info

Gets the version and how full the heap is.

RegisterInput
AH00h
Inputs for Info

Returns the version in AH and AL, the heap's segment in BX, its size in cells in CX, and the number of free cells in DX.

    ;  Show the size of the heap and how much of it is free.
  mov ah, 00h
  int 3Dh
  push dx
  mov si, offset _msg_heap
  call print
  mov ax, cx
  call print_hex
  mov si, offset _msg_free
  call print
  pop ax
  call print_hex
The tester showing how big the heap is and how much is free

10h - Create face

Adds a face to a shape.

RegisterInput
AH10h
DS:SINine words, the x, y and z of each corner, then the colour
ESThe shape
Inputs for Create face

Returns the new face in AX.

    ;  Add the 12 faces to the shape.
  mov es, ax
  mov si, offset _faces
  mov cx, 12
tester_face:
  mov ah, 10h
  int 3Dh
  jc tester_error
  add si, 20
  loop tester_face
The tester adding the box's 12 faces to its shape, 20 bytes of data each

11h - Delete face

Takes a face out of its shape and frees its cell.

RegisterInput
AH11h
ESThe face
Inputs for Delete face

Returns the shape it was in, in AX.

  mov ah, 11h
  mov es, _face
  int 3Dh
Deleting a face

14h - Create shape

Adds a shape to a form.

RegisterInput
AH14h
DS:SIThree words, the x, y and z of the shape's centre
ESThe form
Inputs for Create shape

Returns the new shape in AX.

  mov es, ax
  mov ah, 14h
  mov si, offset _centre
  int 3Dh
  jc tester_error
The tester adding a shape, centred on the origin, to the form it has just made

15h - Delete shape

Takes a shape out of its form and frees its cell. Its faces aren't freed, so delete those first.

RegisterInput
AH15h
ESThe shape
Inputs for Delete shape

Returns the form it was in, in AX.

  mov ah, 15h
  mov es, _shape
  int 3Dh
Deleting a shape

18h - Create object

Adds an object to a group. A new object has no form, so give it one with function 1Bh before rendering.

RegisterInput
AH18h
DS:SI22 bytes: the centre as three words, the position as three double words, the x, y and z angles as bytes, and the rotation order
ESThe group
Inputs for Create object

Returns the new object in AX.

    ;  Make the four objects in the group, and give each one the box form.
  mov si, offset _objects
  mov cx, 4
tester_object:
  mov ah, 18h
  mov es, _group
  int 3Dh
  jc tester_error
  mov es, ax
  mov ah, 1Bh
  mov bx, _form
  int 3Dh
  jc tester_error
  add si, 22
  loop tester_object
The tester making its four objects, each from 22 bytes of data, and giving each one the box form

19h - Delete object

Takes an object out of its group and frees its cell.

RegisterInput
AH19h
ESThe object
Inputs for Delete object

Returns the group it was in, in AX.

  mov ah, 19h
  mov es, _object
  int 3Dh
Deleting an object

1Ah - Move object

Gives an object a new centre, position and orientation, from data laid out the same as for Create object.

RegisterInput
AH1Ah
DS:BX22 bytes, the same as for Create object
ESThe object
Inputs for Move object

Returns with carry clear if it worked.

  mov ah, 1Ah
  mov bx, offset _objects
  mov es, _object
  int 3Dh
Moving an object

1Bh - Change object form

Sets the form an object is drawn with, or with BX set to 0, just finds out what it is.

RegisterInput
AH1Bh
BXThe new form, or 0
ESThe object
Inputs for Change object form

Returns the old form in AX.

  mov es, ax
  mov ah, 1Bh
  mov bx, _form
  int 3Dh
  jc tester_error
The tester giving a new object the box form

1Ch - Create group

Adds a group to the universe.

RegisterInput
AH1Ch
DS:SI22 bytes, laid out the same as for Create object
ESThe universe
Inputs for Create group

Returns the new group in AX.

    ;  Make a group in the universe, to turn the whole scene.
  mov ah, 1Ch
  mov si, offset _groupdata
  mov es, _universe
  int 3Dh
  jc tester_error
  mov _group, ax
The tester adding its group to the universe

1Dh - Delete group

Takes a group out of the universe and frees its cell. Its objects aren't freed, so delete those first.

RegisterInput
AH1Dh
ESThe group
Inputs for Delete group

Returns the universe in AX.

  mov ah, 1Dh
  mov es, _group
  int 3Dh
Deleting a group

1Eh - Move group

Gives a group a new centre, position and orientation. Everything in the group moves with it.

RegisterInput
AH1Eh
DS:BX22 bytes, the same as for Create object
ESThe group
Inputs for Move group

Returns with carry clear if it worked.

  mov ah, 1Eh
  mov bx, offset _groupdata
  mov es, _group
  int 3Dh
Moving a group and everything in it

20h - Create form

Makes a new, empty form.

RegisterInput
AH20h
DS:BXThree words, the x, y and z of the form's centre
Inputs for Create form

Returns the new form in AX.

    ;  Make the box form, with one shape in it.
  mov ah, 20h
  mov bx, offset _centre
  int 3Dh
  jc tester_error
  mov _form, ax
The tester making the box form

21h - Delete form

Frees a form's cell. It doesn't check whether any objects still use the form, so take it off them first.

RegisterInput
AH21h
DSThe form
Inputs for Delete form

Returns with carry set if DS isn't a form.

  mov ah, 21h
  mov ds, _form
  int 3Dh
Deleting a form

40h - Find free cell

Finds the first free cell on the heap, without taking it. The create functions use this.

RegisterInput
AH40h
Inputs for Find free cell

Returns the cell's segment in AX, or carry set if the heap is full.

  mov ah, 40h
  int 3Dh
  jc heap_full
Checking there's room on the heap

80h - Render

Draws the whole scene into a 320x200 buffer, which can be the screen at A000h or any other 64k segment.

RegisterInput
AH80h
DSThe universe
ESThe buffer to draw into
Inputs for Render

Returns with carry set and an error code in AX if it found a bad structure on the way.

    ;  Render the universe into the buffer.
  push ds
  mov ah, 80h
  mov ds, _universe
  int 3Dh
  pop ds
  jc tester_error
The tester rendering the universe into its buffer, which ES already points at after clearing it

88h, 8Ah, 8Ch and 8Eh - Draw group, object, shape and face

The steps of rendering, one level each, which can also be called on their own. Each one calls the next for everything it holds, down to the face, which works out where the face is on the screen and fills it.

RegisterInput
AH88h, 8Ah, 8Ch or 8Eh
DSThe group, object, shape or face
BXFor a shape or a face, the object it's being drawn for
ESThe buffer to draw into
Inputs for the four draw functions

Each returns with carry set and an error code in AX if it found a bad structure.

  mov ah, 8Ah
  mov ds, _object
  mov es, _buffer
  int 3Dh
Drawing one object on its own

94h - Triangle

Fills a triangle in one colour. It's what the faces are drawn with, and it's handy on its own. There's no clipping, and it sorts the corners in place.

RegisterInput
AH94h
ALThe colour
DS:SISix words, the x and y of each corner on the screen
ESThe buffer to draw into
Inputs for Triangle

Returns nothing.

  mov ah, 94h
  mov al, 15
  mov si, offset _corners
  mov es, _buffer
  int 3Dh
  ...
_corners	dw 160, 20, 300, 180, 20, 180
Filling a white triangle

Inside the handler

Logging on points the INT 3Dh vector at 3DOMS's handler, which compares AH with each function number in turn:

int3D_intvect:
    ;  Branch to the required function.
  cmp ah, 00h
  je int3D_info_

  cmp ah, 10h
  je int3D_newface_
  cmp ah, 11h
  je int3D_delface_
  ...
The start of the INT 3Dh handler (trimmed)

Even inside 3DOMS, the renderer calls itself through the interrupt. Rendering calls function 88h for each group, which calls 8Ah for each object, and so on down to 94h for each triangle.

Errors come back in the carry flag, the way DOS does it. An interrupt handler returns with IRET, which pops the caller's flags off the stack, so to set or clear carry the handler pops the return address and flags, changes bit 0 and pushes them back:

int3D_end:
  pop word ptr cs: [_int3D_calloff]
  pop word ptr cs: [_int3D_callseg]
  pop word ptr cs: [_int3D_callflag]
  and word ptr cs: [_int3D_callflag], 0FFFEh
  push word ptr cs: [_int3D_callflag]
  push word ptr cs: [_int3D_callseg]
  push word ptr cs: [_int3D_calloff]
  iret
Clearing carry in the flags that IRET will restore

Just before the handler is the string "3DOMSv" followed by 0 and 10h, for telling whether 3DOMS is already loaded as a TSR. As it is, it's assembled into the program that uses it.

Logging off frees the heap first and then puts the old vector back. The other way round, the computer rebooted, and the comments admit "I never found out why it did it".

Not finished

Plenty of it is still to do, or doesn't quite match its documentation yet:

  • The sorting by distance is designed but not written. The documentation says groups and shapes are drawn starting with the furthest away, but the loops say "don't worry about order yet" and draw them in list order.
  • The documentation has functions for moving the camera, changing the view area, drawing lines and drawing shaded triangles, but they're not in the handler. There are variables for the camera's position and rotation and for the view area, but nothing uses them, so the view is always the whole 320x200 screen.
  • The documentation says rendering clears the buffer first, but it doesn't.
  • Moving an object or a group, and creating a form, read their data from DS:BX rather than DS:SI as the documentation says.
  • The routine shared by all the create functions adds one to the count of objects, so that count goes up for every structure.
  • Deleting a child from a bad parent returns the error code with carry clear, so it looks as if it worked.
  • The cross product only keeps 16 bits of each product, so a big enough triangle could overflow and be left out by mistake.

The documentation also plans what comes after flat colours: bitmap textures, RGB colours dithered onto an orthogonal palette, reflections and smooth shading ("for fast computers only!"), light sources and distance fading.

Source code

Engine

  • 3DOMS012.INC Assembly · 2790 lines · the 3D system, with its documentation at the top

Tester

  • TESTER.ASM Assembly · 330 lines · the four-box demo, which includes 3DOMS012.INC; built with Turbo Assembler 3.2

Download