3D Synthezier

Table of Contents

Back to BASIC applications collection

1. Operating principle

Parses scene definition language and creates 3D world based on it. Result will be in a wavefront obj file, witch can be then visualized using external renderer.

Basic concept of defining scene is:

  • Simple and primitive objects are created on point and polygon level.
  • More complex ones can be created my combinig already existing ones, while applying various transformations on them.

Objects with all its subobjects can be rotated, mirrored or resized omong any axis. Generator has built in cache for data input and output to minimize file access.

Generated scenes are versioned alongside the source, so the cities can be downloaded and imported directly - no generator run needed:

file contents
city1.obj rectangular city (~28k faces)
city2.obj hexagonal city (~211k faces)
result.mtl shared material library

They are plain wavefront obj files and import into Blender or any other renderer as-is.

1.1. Rectangular city

rectangular city, 1.jpeg

The rectangular city at night under its single light source: four square towers with lit facades facing the light and gray shadow sides facing away. The city1.3d script places four building objects (three of them rotated with xz90) plus rows of cars parked at various heights. Each building is 32 stacked storey_square floors crowned by a randomly picked roof variant (roof, roof2 or roof3).

rectangular city, 2.jpeg

A close aerial view of a roof-parking variant: the flat stone slab carries buses and police cars (bus and police sub-objects) next to a tilted solar panel and pipes. Facades are assembled from window_big and window_small panels - wall_brick quads with recessed glass. On large flat surfaces like this roof the per-texel GI lightmap grid is visible up close.

1.2. Hexagonal city

hexagonal city, 1.jpeg

A distant view of the hexagonal city at dusk: a cluster of hexagonal towers crowned with translucent domes rises from a low-poly plain under an orange sky. The city2.3d script builds this by stacking block sub-objects and placing stone ring and glass dome (dome) objects at hexagon vertex offsets - multiples of sin and cos of 30 degrees - scaled twentyfold with the x*20 z*20 size modifiers.

hexagonal city, 2.jpeg

A closer aerial look at the hexagonal tower cluster, showing the blue-tinted glass domes sitting on their stone rings with roads threading between the towers. Each dome and ring is a separate sub-object pulled from the include directory and positioned by the use command, while materials are switched between stone (stone) and glass_transp with mtl commands before each placement.

hexagonal city, 3.jpeg

Street-level view inside the hexagonal city: low-poly cars in blue, yellow and red dot the roads between the towers. The vehicles are the same car sub-objects reused dozens of times with different x, z and y offsets and xz rotations, illustrating how the language composes complex scenes from a handful of cached primitive objects.

2. Scene description language

A scene is a plain text file with one command per line. The parser reads a line, splits it into words (spaces or tabs), substitutes variables, and executes the first word as a command. Two kinds of files play different roles:

  • scene file (bin/city1.3d, bin/city2.3d) - the entry point given on the command line. Opens the output file with out, then instantiates objects with use.
  • object files (include/*.3d) - reusable building blocks: walls, windows, cars, buildings. They define raw geometry with p, origin, f and mtl, and can instantiate further objects themselves.

The rectangular and hexagonal cities above were described entirely in this language; the source scripts are bin/city1.3d and bin/city2.3d.

from scene script to rendered city city1.3d out city1 use building xz90 x+48 use cars x+25 # comments scene file (bin/) include/*.3d building.3d - a building cars.3d - vehicles window_big.3d - a window 38 reusable objects object library (include/) 3dparse.bas read line (cached) split into words substitute %vars execute command 10-file LRU cache 500 lines per file vertex buffer x/y/z, 1000 slots flush when > 900 face buffers 100 per material flush when > 90 city1.obj + result.mtl library v / usemtl / f lines bin/city1.sh runs crtbasic 3dparse.bas --headless --command=city1 - COMMAND$ picks the scene

Command summary (each has a full section below):

command purpose
out open the output .obj file (first command)
origin set the origin that face indices count from
p define a vertex
f define a triangle or quad face
mtl select the material for following faces
use instantiate a sub-object, with transforms
pickmtl select one material at random
pick execute one alternative at random
dum no-op placeholder used to weight pick odds
set set a variable
%n substitute a variable into any line
ifeq compare two words, set a condition flag
if execute a command only when its flag is set
# comment line

The old spellings here, obj, rnd, mtlrnd, cmp and ?<slot> remain valid aliases, so scenes written before the rename keep working unchanged.

warn print a warning and wait for a keypress
end flush output and terminate immediately

2.1. A minimal scene

Everything about the geometry commands fits into one tiny example. An object file tent.3d (reusable, so it starts with origin and has no out or end):

# A tent: floor, two roof slopes, two triangular ends.
origin

# ground rectangle
p 0 0 0
p 4 0 0
p 4 0 6
p 0 0 6

# ridge line
p 2 3 0
p 2 3 6

mtl stone
f 0 1 2 3
f 0 1 4
f 3 2 5

mtl roof
f 0 4 5 3
f 1 2 5 4

A scene file street.3d that stamps out three copies of it with different transforms (the ~ prefix loads from the program directory instead of the include path):

# Three tents in a row: same object, different transforms.
out street
use ~tent
use ~tent x+8 z+2
use ~tent x+16 y*1.5

Running crtbasic 3dparse.bas --headless --command=street (DOS: qb /RUN 3dparse.bas /CMD street) produces this exact street.obj, unedited:

mtllib result.mtl
v  0  0  0
v  4  0  0
v  4  0 -6
v  0  0 -6
v  2  3  0
v  2  3 -6
v  8  0 -2
v  12  0 -2
v  12  0 -8
v  8  0 -8
v  10  3 -2
v  10  3 -8
v  16  0  0
v  20  0  0
v  20  0 -6
v  16  0 -6
v  18 4.5  0
v  18 4.5 -6
usemtl stone
f  1 4 3 2
f  1 5 2
f  4 6 3
f  7 10 9 8
f  7 11 8
f  10 12 9
f  13 16 15 14
f  13 17 14
f  16 18 15
usemtl roof
f  1 4 6 5
f  2 5 6 3
f  7 10 12 11
f  8 11 12 9
f  13 16 18 17
f  14 17 18 15

Reading it line by line teaches most of the format:

  • 18 vertices = 3 instances x 6. Instance 2 is offset by x+8 z+2, instance 3 by x+16, and its y*1.5 scale stretched the ridge from y=3 to y=4.5.
  • Z is negated on output: tent z=6 becomes -6 in the OBJ. The scene language and Wavefront OBJ disagree on handedness; the parser flips Z while writing.
  • Faces come out with their vertex order reversed: source f 0 1 2 3 is written as f 1 4 3 2. Flipping Z is a mirror, and reversing the face restores source-space winding (see f).
  • Faces are not grouped by tent. All stone faces (floors and ends of all three tents) come first, then all roof slopes: output is grouped by material, because faces are buffered per material.
  • Indices run from 1 to 18 continuously across instances - OBJ numbers vertices from 1, while f lines in the source count from the most recent origin starting at 0.
  • The first line references result.mtl, the shared material library that must sit beside the .obj file.
the tent - 6 vertices, 5 faces (3 quads, 2 triangles) 1 2 3 4 5 6 stone - floor + both ends (3 faces) roof - two roof slopes (2 faces) vertices (origin-relative): v1 0 0 0 v4 0 0 6 v2 4 0 0 v5 2 3 0 v3 4 0 6 v6 2 3 6 faces: f 0 1 2 3 floor f 0 1 4 front end f 3 2 5 back end f 0 4 5 3 left slope f 1 2 5 4 right slope face indices count from the origin line (0-based) y is up in scene space; OBJ output negates z and reverses faces

2.2. out

out  file

Opens file.obj for writing and emits the mtllib result.mtl header line. Use it exactly once, as the first command of the scene file, before any geometry: every vertex and face written afterwards goes to this file. All output is buffered, so the file only grows in blocks while the scene executes (see Parser internals).

Both city scripts begin with it:

# small city block
out city1

use building xz90
...

2.3. origin

origin

Records the number of vertices defined so far. From then on, f indices count from that point starting at 0.

This is the trick that makes objects reusable: an object file starts with origin, so its faces only ever reference its own vertices, no matter where in the scene the instance lands. The parser adds the recorded offset (and OBJ's 1-based numbering) when writing, so the same source line f 0 1 2 3 becomes f 1 4 3 2 in the first tent and f 7 10 9 8 in the second.

origin-relative indices become absolute OBJ indices same source lines (tent.3d) instance 1 - origin at vertex 0 f 0 1 2 3 +1 street.obj f 1 4 3 2 instance 2 - origin at vertex 6 f 0 1 2 3 +6, +1 street.obj f 7 10 9 8 f counts from the most recent origin (0-based); OBJ numbers vertices from 1 across the whole file - so instance 2 lands on vertices 7-12 identical object lines, different absolute indices: this is what makes instancing work OBJ also reverses each face's vertex order for winding - see the f command

A scene-level file that only composes objects (like city1.3d) never needs origin - only files that define raw faces do.

Legacy spelling: here - the parser accepts both.

2.4. p

p  x y z

Defines one vertex. Before the coordinates are stored, every transform on the active use stack is applied to them, so a p inside an included object lands wherever the instance was moved, rotated or scaled to.

  • Coordinates are numbers: integers, negatives and decimals all work (p 0.5 -0.5 -0.01 appears in window_big.3d).
  • Words may be separated by spaces or tabs; several include files use tabs.
  • Vertices are numbered in definition order across the whole run and stream into the OBJ as v lines in that same order, with Z negated.
  • Buffer: 1000 vertices are held in memory, flushed in definition order whenever the buffer passes 900 entries. Flushing changes nothing about indices.

2.5. f

f  p1 p2 p3 p4

Defines one face - a triangle when p4 is omitted, a quad when it is present. Indices count from the most recent origin, starting at 0; in the OBJ they appear absolute and 1-based.

  • Winding is not preserved verbatim: vertex output negates Z (a mirror), so faces are emitted in reversed order to keep the source-space orientation intact. Net effect: author faces so that the right-handed normal cross(v2-v1, v3-v1) points out of the solid, and that is what the OBJ will contain.
  • Instances placed with a negative scale (e.g. x*-1) are mirrored; the parser tracks the parity of negative scales on the transform stack and flips winding back automatically. Odd count of negative scales = mirrored = winding flipped once more.
  • Faces are buffered per current material (100 entries), then written as a run behind a single usemtl line. This is why OBJ output is grouped by material rather than by object.
  • Indices may reach back past the current object - they are just offsets into the global vertex list. prism4.3d uses this together with if to optionally cap a prism: if 0 f 0 1 2 3.

2.6. mtl

mtl  material

Selects the material that following f faces are stored under. The first use of a name appends it to the material table (up to 50 materials); using it again later just re-selects it.

Materials are only names at this stage - their colors and textures live in the shared result.mtl library that the OBJ references on its first line. A name missing from result.mtl still ends up in the output; the renderer will complain about it, not the generator.

Real usage from city2.3d, switching between stone and glass before each placement:

mtl stone
use ring x*20 z*20 y+26
mtl glass_transp
use dome x*20 z*20 y*10 y+26

2.7. use

use  object xz45 xy20 x+3 y*2

Instantiates another .3d file: its lines are executed right there, as if typed in place (then the instance picks up its transforms). Legacy spelling: obj - the parser accepts both. inside the current context. Objects instantiate further objects freely - city1.3d calls building, which calls storey_square (a floor) thirty times, which calls wall_square / wall_hex (walls), which call window_big / window_small (windows).

2.7.1. Where the file is found

A plain name loads <include path>/<object>.3d (the include directory). A name prefixed with ~ loads <object>.3d from the program directory instead - that is how the scene files themselves are pulled in: the launcher effectively runs use ~city1.

Loaded files are kept in a 10-file cache, so the 30 floors of a building cost one disk read, not thirty (see Parser internals).

2.7.2. Transforms

Every argument after the file name is a transform applied to each vertex the object (and its nested objects) defines:

op effect
xz<deg> rotate in the X-Z plane (+X toward +Z)
yz<deg> rotate in the Y-Z plane (+Y toward +Z)
xy<deg> rotate in the X-Y plane, mirrored (+X toward +Y)
x+ / y+ / z+ translate along an axis
x- / y- / z- subtract along an axis
x* / y* / z* scale one coordinate

Angles are in degrees, may be negative (xz-90), and translations and scales accept decimals (x+55.9809 in city2.3d). The arguments are pushed onto a stack - they apply to each vertex in their written order, innermost object first, enclosing objects after:

use ops are stacked, then applied top-down at every p use building xz90 x+48 arguments after the file name xz90 - rotate x+48 - translate top of stack - applies first pushed last argument first, so the stack pops them in their written order p 2 0 3 -> xz90: (-3, 0, 2) -> x+48: (45, 0, 2) stored as vertex nested use calls append to the same stack: enclosing transforms apply after the inner ones x+ / y+ / z+ translate, x- / y- / z- subtract, x* / y* / z* scale a coordinate stack depth limit 500; rotation operands are degrees, negative angles allowed (xz-90)

Typical lines from bin/city1.3d - rotate a building upright and move it over, or park cars at several altitudes:

use building xz90 x+48
use cars x+26 z-25 y-10
the three rotation planes - angles in degrees xz<deg> 90 x z +X rotates toward +Z z' = x sin + z cos / x' = x cos - z sin yz<deg> 90 y z +Y rotates toward +Z z' = y sin + z cos / y' = y cos - z sin xy<deg> 90 x y mirrored handedness (det -1) +X rotates toward +Y y' = y cos + x sin / x' = y sin - x cos measured: xz90 sends (10,0,0) to (0,0,10) with a 0.00154 residue - the generator's pi is 3.141285, and the bundled scenes depend on it xy mirrors while rotating (improper rotation) - the cities are built around this, so it stays

Two measured quirks of the rotation math, both part of the file format by now:

  • The X-Y rotation is mirrored: its matrix has determinant -1, so xy reflects while it rotates (that is also where the "mirrored" in the overview comes from). The bundled scenes were built around this behavior, so it is documented rather than fixed.
  • The degrees-to-radians factor uses pi = 3.141285, so cos(90) leaves a visible 0.00154 residue in rotated coordinates. Harmless for rendering, but it means the value must never be "corrected" - the generated geometry depends on it.

2.7.3. A correction to older documentation

This page previously described a trailing ~ suffix that would read an object directly from disk, bypassing the cache to allow files larger than 500 lines. The parser has no such mode - only the leading ~ (program directory) exists. Every object goes through the same cache and the same 500-line limit per file.

2.8. pickmtl

pickmtl  material ...

Selects one of the listed materials at random - each listed name has the same chance, so repeating a name weights it (pickmtl a a b picks a two thirds of the time).

Legacy spelling: mtlrnd - the parser accepts both.

Used for variety without extra scripting. Every window block in the cities picks its glass tone per instance (window_big.3d / window_small.3d):

pickmtl glass_light glass_dark

That one line is why some windows render as light glass and others as dark glass across the towers. The dice are seeded from the clock (RANDOMIZE TIMER), so every generator run produces a slightly different city.

2.9. pick

pick  command1 command2 ...

Executes exactly one of the alternatives, chosen uniformly at random. Because spaces separate words, a caret ^ stands in for a space inside an alternative: use^car^xz91 executes as use car xz91.

From include/wall_hex.3d - a wall randomly gets a police car, a plain car, a bus, or (most often) nothing at all, thanks to dum padding:

pick use^police^xz-92^z-6^x+1 dum dum dum dum
pick use^car^xz91^z-10^x+5 dum dum dum dum
pick use^car^xz87^z-8^x+19 dum dum dum dum
pick use^bus^xz-90^z-12^x+10 dum dum dum dum dum dum dum dum

The first three lines each give a 1/5 chance of a vehicle, the bus a mere 1/9. include/cars.3d uses the same trick at 1/2 to sometimes swap a car for a police car. Like pickmtl, the choice is seeded from the clock.

pick executes one alternative, chosen uniformly - dum pads the odds pick use^police^xz-92^z-6^x+1 dum dum dum dum from wall_hex.3d caret expands to a space: use^police^xz-92^z-6^x+1 -> use police xz-92 z-6 x+1 use police ... dum dum dum dum 1/5 chance nothing happens - 4/5 pick use^bus^xz-90^z-12^x+10 dum dum dum dum dum dum dum dum bus dum x 8 1/9 8/9 nothing

2.10. dum

dum

Does nothing - and that is the point. Any word that matches no command is silently ignored, so an unrecognized word like dum is a legal, harmless line. Its one real use is padding the odds inside pick lists: each dum is one more chance that nothing happens. It is a convention, not a command - banana would work exactly the same.

2.11. set

set  variable  contents

Stores contents in variable variable. Variables are numbered 0-100 (101 slots) and hold a single word.

Variables are global: once set, every line executed afterwards - in any file, at any nesting depth - sees the value, until something overwrites it. Callers use this to parameterize the objects they instantiate; include/hexcluster.3d configures the hexagonal prism it is about to include:

set 1 stone
set 2 0
use prism6 x*20 y*5 z*20 y+21

Here variable 1 picks the body material and variable 2 tells prism6 whether to generate end caps (see ifeq and if).

2.12. variables usage

anycommand %1 anything

Before a line executes, every word of the form %n is replaced textually with the contents of variable n. It works in any position of any command - material names, coordinates, transform operands:

mtl %1
use prism6 x*20 y*5 z*20 y+21

Since set values are single words, a substitution always yields exactly one word.

2.13. ifeq

ifeq  flag string1 string2

Compares the two words (after % substitution) and sets condition flag flag when they are equal, clears it otherwise. There are 10 flags, numbered 0-9.

Flags are stored per nesting depth: each level of use nesting owns a fresh row of 10, so a sub-object's flags cannot clash with its caller's. Everything executing at the same depth shares that level's row.

2.14. if

if  flag anycommand

Executes anycommand only when condition flag flag (a single digit, 0-9) is set at the current nesting depth; otherwise the line is skipped entirely.

The complete mechanism, from include/prism4.3d - the end caps of the prism exist only when the caller set variable 2 to 1:

ifeq 0 %2 1
if 0 f 0 1 2 3
if 0 f 4 5 6 7
set, %, ifeq and if - parameterized objects caller (handrail.3d): set 1 bronze, set 2 1 variables: %1 = bronze %2 = 1 prism8.3d: ifeq 0 %2 1 after substitution: ifeq 0 1 1 flag row of this nesting depth 0: 1 1: - 2: - ... - flag 0 set - if 0 line runs if 0 f 0 1 4 5 -> end-cap quad emitted both end faces of the prism appear flag 0 clear - if 0 line skipped same source line, no output open tube instead of capped prism flags live per nesting depth (10 slots, 0-9 per level); set values are single words; %n substitutes anywhere

2.15. #

# whatever text

Comment. Lines whose first character is # are skipped while the file is loaded, as are blank and whitespace-only lines - they never reach the parser or count against the 500-line cache limit.

2.16. warn

warn  message

Prints the word after warn in bright red and waits for a keypress. A debugging aid from the DOS days for flagging suspicious scenes; only the first word of the message is shown. Not used by the city scripts.

2.17. end

end

Flushes all buffered output (like a normal end of file), prints "terminated from file" and exits the whole program immediately.

Rarely needed: falling off the end of the scene file flushes and exits anyway. Never put end in an object file - it would kill the entire run the first time that object was instantiated, leaving a half-written city.

2.18. Parser internals

A few implementation facts that explain both the performance and the shape of the output.

2.18.1. Command cache

Object files are parsed once and their command lines kept in a 10-slot cache, evicted least-recently-used first. A 30-story building re-reads nothing: the floor, wall and window files stay resident while hundreds of instances execute from memory. While the generator runs, the screen shows this very table - the ten slots with file names, line counts and stamps - plus parsed / cache-miss counters.

the 10-file command cache (least recently used eviction) slot file lines stamp 1 building.3d 33 941 2 cars.3d 9 940 3 wall_hex.3d 8 938 4 window_big.3d 13 937 5 storey_hex.3d 6 920 6 bus.3d 40 902 7 car.3d 26 899 8 police.3d 33 871 <- oldest 9 - 10 - cache hit run the stored lines, stamp = ++clock no disk access at all cache miss 1. pick the slot with the smallest stamp 2. evict it 3. load <name>.3d, skip # and blanks 4. stamp = ++clock limit: 500 non-comment lines per file city2 rotates dozens of files through 10 slots during a run the screen shows exactly this table plus the parsed / cache-miss / % counters

2.18.2. Output buffers

Vertices (1000 slots) and faces (100 slots per material) accumulate in memory and flush in blocks. Absolute vertex numbering makes the flushes invisible in the result - but the per-material face buffers are exactly why the OBJ groups faces by material rather than by object.

output is buffered in memory, then flushed in blocks vertex buffer v 1 v 2 v 3 ... flush when > 900 of 1000 indices stay absolute, so flushing is invisible stone faces f 1 f 2 ... flush > 90 roof faces f 1 ... flush > 90 mtllib result.mtl v 0 0 0 v 4 0 0 ... v 18 4.5 -6 usemtl stone f 1 4 3 2 ... f 16 18 15 usemtl roof f 1 4 6 5 ... f 14 17 18 15 vertices stream out in definition order; faces are grouped by material, not by object the OBJ above is the real street.obj from the minimal example - three tents, two material runs

2.18.3. Limits

resource limit
vertex buffer 1000 (flushes past 900)
face buffer per material 100 (flushes past 90)
materials 50
cached files 10
stored lines per cached file 500 (comments/blanks free)
transform stack depth 500
use nesting depth 50 (flag rows)
variables 101 (%0 - %100)
condition flags 10 per depth (0 - 9)

3. Installation

The only requirement is crtbasic, the QBasic-dialect interpreter that runs bin/3dparse.bas. No include-path editing is needed: the parser resolves the relative ..\include\ path against the bin directory.

3.1. System requirements

software notes
crtbasic runs the generator (tested on Linux)

The include files use long descriptive names (window_big.3d, hexcluster_bridges.3d, …) that exceed the DOS 8.3 filename limit, so the project is no longer DOS/QBasic compatible - and no attempt is made to keep it so.

3.2. Directory layout

bin
3dparse.bas
3D generator main executable
city1.3d
city with square-like buildings
city2.3d
city with hexangular buildings
result.mtl
shared material library
city1.obj, city2.obj
generated cities (versioned)
*.sh
quick launch scripts (crtbasic)
include
3D objects used to compose the scene

4. Usage

Run the generator through crtbasic:

bin/city1.sh

or

bin/city2.sh

which executes

crtbasic 3dparse.bas --headless --command=city1

where --command fills COMMAND$ (the DOS /CMD switch). After parsing, the generated city1.obj / city2.obj appear in the bin directory. Visualize the scene with your favourite renderer - or use the versioned OBJ files directly without regenerating.

Created: 2026-10-04 su 23:04

Validate