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.

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 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
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 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 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
...
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
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.
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.
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.
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.
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
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
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).
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:
| Value | Kept as | Format |
|---|---|---|
| Face corners | 16-bit words | 8.8, from -128 to just under 128 |
| Centres of shapes, forms, objects and groups | 16-bit words | 8.8 |
| Positions of objects and groups | 32-bit double words | 24.8 |
Camera distances, scr2eye and scr2org | 32-bit double words | 24.8, so 8000h is 128.0 |
| Sines and cosines | 32-bit double words | 8.8 values, from -100h to 100h |
| Corners being drawn, in the work area | 32-bit double words | 24.8 |
| Products, inside a multiply or a divide | 64 bits, in EDX:EAX | 16 bits after the point, for one instruction or two |
| Screen co-ordinates | 16-bit words | Whole pixels, nothing after the point |
| Angles | Bytes | 256 steps to a turn |
A corner goes through them in order as it's drawn:
- 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.
- 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.
- Positions are added, 24.8 to 24.8.
- 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.
- 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
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
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
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
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
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
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
...
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
...
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
...
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:
- Its three corners are copied out and widened from 8.8 words to 24.8 double words.
- They're rotated by the object's angles, in the object's rotation order, and moved by the object's position.
- Then they're rotated and moved again by the group's.
- Each corner is projected onto the screen.
- If the triangle on the screen is facing away, it's left out.
- 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
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
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
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 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
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:
| Error | Value | Meaning |
|---|---|---|
ERROR_HEAPOUT | -1 | There are no free cells left |
ERROR_BADSHAPE, ERROR_BADFORM, ERROR_BADOBJECT, ERROR_BADGROUP, ERROR_BADUNIVERSE | -3 to -7 | The segment isn't the structure the function wanted |
ERROR_BADPARENT | -8 | The parent isn't the right kind of structure |
ERROR_BADCHILD | -9 | The structure to delete isn't the right kind |
ERROR_TOOMANY | -16 | The parent already has 127 children |
ERROR_BADLIST | -32 | A linked list is corrupt |
00h - Info
Gets the version and how full the heap is.
| Register | Input |
|---|---|
| AH | 00h |
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
10h - Create face
Adds a face to a shape.
| Register | Input |
|---|---|
| AH | 10h |
| DS:SI | Nine words, the x, y and z of each corner, then the colour |
| ES | The shape |
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
11h - Delete face
Takes a face out of its shape and frees its cell.
| Register | Input |
|---|---|
| AH | 11h |
| ES | The face |
Returns the shape it was in, in AX.
mov ah, 11h
mov es, _face
int 3Dh
14h - Create shape
Adds a shape to a form.
| Register | Input |
|---|---|
| AH | 14h |
| DS:SI | Three words, the x, y and z of the shape's centre |
| ES | The form |
Returns the new shape in AX.
mov es, ax
mov ah, 14h
mov si, offset _centre
int 3Dh
jc tester_error
15h - Delete shape
Takes a shape out of its form and frees its cell. Its faces aren't freed, so delete those first.
| Register | Input |
|---|---|
| AH | 15h |
| ES | The shape |
Returns the form it was in, in AX.
mov ah, 15h
mov es, _shape
int 3Dh
18h - Create object
Adds an object to a group. A new object has no form, so give it one with function 1Bh before rendering.
| Register | Input |
|---|---|
| AH | 18h |
| DS:SI | 22 bytes: the centre as three words, the position as three double words, the x, y and z angles as bytes, and the rotation order |
| ES | The group |
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
19h - Delete object
Takes an object out of its group and frees its cell.
| Register | Input |
|---|---|
| AH | 19h |
| ES | The object |
Returns the group it was in, in AX.
mov ah, 19h
mov es, _object
int 3Dh
1Ah - Move object
Gives an object a new centre, position and orientation, from data laid out the same as for Create object.
| Register | Input |
|---|---|
| AH | 1Ah |
| DS:BX | 22 bytes, the same as for Create object |
| ES | The object |
Returns with carry clear if it worked.
mov ah, 1Ah
mov bx, offset _objects
mov es, _object
int 3Dh
1Bh - Change object form
Sets the form an object is drawn with, or with BX set to 0, just finds out what it is.
| Register | Input |
|---|---|
| AH | 1Bh |
| BX | The new form, or 0 |
| ES | The object |
Returns the old form in AX.
mov es, ax
mov ah, 1Bh
mov bx, _form
int 3Dh
jc tester_error
1Ch - Create group
Adds a group to the universe.
| Register | Input |
|---|---|
| AH | 1Ch |
| DS:SI | 22 bytes, laid out the same as for Create object |
| ES | The universe |
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
1Dh - Delete group
Takes a group out of the universe and frees its cell. Its objects aren't freed, so delete those first.
| Register | Input |
|---|---|
| AH | 1Dh |
| ES | The group |
Returns the universe in AX.
mov ah, 1Dh
mov es, _group
int 3Dh
1Eh - Move group
Gives a group a new centre, position and orientation. Everything in the group moves with it.
| Register | Input |
|---|---|
| AH | 1Eh |
| DS:BX | 22 bytes, the same as for Create object |
| ES | The group |
Returns with carry clear if it worked.
mov ah, 1Eh
mov bx, offset _groupdata
mov es, _group
int 3Dh
20h - Create form
Makes a new, empty form.
| Register | Input |
|---|---|
| AH | 20h |
| DS:BX | Three words, the x, y and z of the form's centre |
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
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.
| Register | Input |
|---|---|
| AH | 21h |
| DS | The form |
Returns with carry set if DS isn't a form.
mov ah, 21h
mov ds, _form
int 3Dh
40h - Find free cell
Finds the first free cell on the heap, without taking it. The create functions use this.
| Register | Input |
|---|---|
| AH | 40h |
Returns the cell's segment in AX, or carry set if the heap is full.
mov ah, 40h
int 3Dh
jc heap_full
80h - Render
Draws the whole scene into a 320x200 buffer, which can be the screen at A000h or any other 64k segment.
| Register | Input |
|---|---|
| AH | 80h |
| DS | The universe |
| ES | The buffer to draw into |
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
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.
| Register | Input |
|---|---|
| AH | 88h, 8Ah, 8Ch or 8Eh |
| DS | The group, object, shape or face |
| BX | For a shape or a face, the object it's being drawn for |
| ES | The buffer to draw into |
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
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.
| Register | Input |
|---|---|
| AH | 94h |
| AL | The colour |
| DS:SI | Six words, the x and y of each corner on the screen |
| ES | The buffer to draw into |
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
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_
...
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
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
- 3doms-tester.jsdos js-dos bundle · the tester, ready to run in DOSBox