A Look at .ray Files

What follows is a simple introduction to the file format used by the raytracer. This should help you write new scenes. Also, it should give you some sense of how to extend the file format with new declarations to support any novel features you add to the raytracer.

The general approach taken is to break down the process of reading the input file into two steps. The first step turns the file into a sequence of tokens. The second takes that sequence of tokens and turns it into the internal scene representation.

The Grammar

Because of the two step process, the syntax for raytracer input files is extremely simple. The parser will accept any file containing a tag line followed by a sequence of objects.

For this version of the raytracer, the tag line must be

SBT-raytracer 1.0

And must appear at the very start of the file.

An object can be one of several different things:

Type Description Examples
Scalars: Any integer or floating point number is an object 4
-1
0.00005
-1E6
String Literals: String literals, beginning and ending with a double quote and possibly containing C-style escaped characters, are objects. "Hello"
"blah blah\nblah"
IDs: Any C-style identifier (a string of alphanumeric characters) is an object. foo
bar
baz
dogs_everywhere
Booleans: The special reserved IDs true and false are boolean objects. true
false
Tuples: A tuple object corresponds to a vector of subobjects. It consists of an open parenthesis, a comma-delimited sequence of objects, and a close parenthesis. ()
(1.0,1.0,1.0)
(1,"hello",foo,(7,6))
Dictionaries: A dictionary is like a struct in C, but the field names are unknown in advance. It's written as an open brace, a semicolon-delimited list of assignments of objects to IDs, and a close brace. {}
{ silly = true }
{ position = (1.0,1.0,2.0); radius = 3 }
Named Objects: Any object can be named. Naming an object simply means putting an ID before it. The ID becomes an additional tag for the object. orange "crush"
sphere { radius = 2 }
material { ks = (1,0,0); ka = (0,0,0) }
Groups (new!): You can group a collection of geometries together under a single transform by enclosing them in braces scale(2, { sphere{} cylinder{} } )

One more note: the parser supports both C and C++ style comments. But make sure that the SBT-raytracer line is always first in the file!

Describing a scene

The format given above is more general than the set of files that describe scenes. Now that we have the file format, here are the kinds of objects that can actually appear in the input file. Note that both "colour" and "color" are recognized by the system.

Name Description Example
camera The camera declaration describes the position and orientation of the virtual camera and the geometry of the frustum. It has the following parameters:
  • position: the 3D position of the eye.
  • viewdir: the direction in which the camera is looking.
  • updir: the orientation of the camera with respect to viewdir (which way is up?).
  • aspectratio: the aspect ratio of the projection plane (width/height). Should probably correspond to the aspect ratio of your final image.
  • fov: the angle of the vertex of the frustum, measured in degrees.
camera {
  position = (0,0,-4);
  viewdir = (0,0,1);
  updir = (0,1,0);
  aspectratio = 1;
}
point_light A point_light is a light source where energy radiates equally in all directions from a single point. It has two parameters:
  • position: the 3D position of the point source.
  • colour: the colour of the emitted light.
point_light {
  position = (1,3,-2);
  // yellow light.
  colour = (1,1,0);
}
directional_light A directional_light is a light source where energy radiates equally in a direction from infinitely far away. It has two parameters:
  • direction: the direction vector of the directional source.
  • colour: the colour of the emitted light.
directional_light {
  direction = (0,0,1); 
  // cyan light.
  colour = (0,1,1);
}
sphere A sphere is a unit (radius 1) sphere centered at the origin. It has no intrinsic parameters, but like other geometry types, it can be transformed, and it must be assigned a material. These are both discussed below.
sphere {
  material = { diffuse=(1,0,0) };
}

box A box is a unit (side length 1) cube centered at the origin (it goes from (-0.5,-0.5,-0.5) to (0.5,0.5,0.5)). Like the sphere, it has no intrinsic parameters.
box {
  material = { diffuse=(1,0,0) };
}
square A square is a unit (side length 1) square centered at the origin and lying in the XY plane (its opposite corners are (-0.5,-0.5,0) and (0.5,0.5,0)). No intrinsic parameters.
square {
  material = {
    diffuse = (1,0,0);
  }
}
cylinder A cylinder is a radius 1 cylinder. Its central axis lies on the Z axis and its ends are at Z = 0 and Z = 1. It has one intrinsic parameter:
  • capped: a boolean indicating whether the cylinder has caps or not.
cylinder {
  material = { diffuse=(1,0,0) };
  capped = false;
}
cone A cone is a generalized cylinder with central axis on the Z axis. It takes a height parameter, and runs from Z = 0 to Z = height. The radius of each endpoint can be specified, and caps can be turned on and off.
  • capped: a boolean indicating whether the cone has caps or not.
  • height: a scalar giving the height of the cone.
  • bottom_radius: a scalar giving the radius at Z = 0.
  • top_radius: a scalar giving the radius at Z = height.
cone {
  material = { diffuse=(1,0,0) };
  capped = true;
  height = 2;
  bottom_radius = 1;
  top_radius = 0;
}
trimesh A trimesh is a big container for polygonal data. It allows you to give a set of vertices and a set of faces based on those vertices. It has the following parameters:
  • points: A tuple of the points in the mesh.
  • normals: A tuple with one normal for each point. (optional)
  • materials: A tuple of materials, one for each point. (optional)
  • faces: A tuple of faces. A face is a tuple of indices. Each index specifies one of the points (counting from zero), and each tuple of indices specifies the points in a face in counter-clockwise order.
  • gennormals: If this is set to be true then per-vertex normals will be automatically generated for the mesh.

Note:

  • If either normals or materials is specified they must have the same number of elements as points.
  • If normals aren't specifed or generated, the mesh will be rendered without Phong interpolation.
  • A single material may be specifed for the whole mesh using the material (sans s) just like other objects. If the 'materials' parameter is specified, then this parameter is optional, else it is required.
trimesh {
  material = { diffuse=(1,0,0) };
  points = ( 
    (0,0,0), 
    (0,1,0), 
    (0,1,1), 
    (0,0,1),
    (1,0,0), 
    (1,1,0), 
    (1,1,1), 
    (1,0,1) );
  faces = ( 
    (2,3,7,6), 
    (1,5,4,0), 
    (2,1,0,3),
    (6,7,4,5), 
    (2,6,5,1), 
    (3,0,4,7) );
  materials = ( 
    { diffuse=(1,0,0) },
    { diffuse=(0,1,0) },
    { diffuse=(0.3,0.2,0) }, 
    { diffuse=(0,1,0) },
    { diffuse=(1,0,0) }, 
    { diffuse=(1,1,0) },
    { diffuse=(0.4,0.3,0.3) }, 
    { diffuse=(1,0,1) } );
}

translate Each of the primitives described above (sphere, cylinder, etc) can have a nested set of transforms above it. A translate declaration wraps the lower-level object inside a translation matrix. The example shows how to get a sphere centered at (1,1,2).
translate( 1,1,2, sphere {
  material = { diffuse=(1,0,0) };
});
scale Scales the lower-level object. Both proportional and nonproportional scale are supported.
// gives an ellipsoid
scale( 1,5,5, sphere {
  material = {diffuse=(1,0,0)};
} );
// give a small sphere
scale( 0.2, sphere {
  material = { diffuse=(1,0,0) };
});
rotate A generalized rotation about a given axis. A vector is given as the axis of rotation, followed by the angle to rotate by (in radians!).
// rotate a scaled cylinder 
// about the X axis by 90 
// degrees
rotate(1,0,0,1.57, 
  scale( 0,0,3, cylinder { material = { diffuse=(1,0,0) };}));
transform The mother of all transformations. Apply an arbitrary 4x4 matrix to the underlying geometry.
// I don't even want to know 
// what this does.
transform( 
    (1,2,3,4),
    (5,6,7,8),
    (9,10,11,12),
    (0,0,0,1), cylinder {  material = { diffuse=(1,0,0) };});
material Give the properties that describe what a surface looks like. This corresponds mostly to the parameters of the Phong lighting model.
  • emissive: ke, the emissive color.
  • ambient: ka, the ambient color.
  • specular: ks, the specular color.
  • reflective: kr, the reflective color. Defaults to ks if not specified.
  • diffuse: kd, the diffuse color.
  • transmissive: kt, the ability for this material to transmit light in each channel (as a 3-tuple).
  • shininess: ns, the shininess (between 0 and 1).
  • index: the material's index of refraction.
  • name: the material's name, which can be used to create a top-level declaration by that name which can be reused later.

Materials can be used in two ways: inline or declared. Inline materials are defined as they are attached to primitives:

sphere {
  material = {
    diffuse = (1,0,0.4);
    specular = (1,1,1);
  }
}

A declared material is given a name at the top level and referred to later:

material {
  name = "gold";
  diffuse = (0.9,0.9,0);
  specular = (1,1,0);
  emissive = (0.1,0.1,0);
  shininess = 0.92;
}

box {
  material = "gold";
}

Hacking the Format

The trace parser is designed to be fairly extensible, and it should be possible to add support for whatever you need. It consists of two main modules, the tokenizer and the parser. The tokenizer does nothing more than split the input up into distinct tokens, while the parser does the actual work of converting these into a scene in memory.

The operation of the tokenizer is quite simple, and it is unlikely that you would ever have a need to modify this. You are, however, likely to need to add keywords in token.{h,cpp}, because unregistered keywords are rejected as syntax errors by the parser. See below for instruction on how to do this.

The parser is a more complex beast, but its basic operation is as follows: as it reads the stream of tokens, it looks at the next one and uses this token to make a guess at what the user is intending; that is, if it sees the "sphere" keyword, it calls the "parseSphere" method. Thus, it is important that any extensions that you make to the language be easily recognizable by a single token. There are a number of helper functions in the parser, like "parseVec3f" and "parseScalar", that can take care of many of the dirty details for you, so that you can focus on the higher-level language.

Now I'll include a couple of sample modifications to the raytracer.

Suppose for of all that we want to add a new primitive, the "pseudosphere". The first thing we need to do is open up Token.h and find the enum listing all the token types. We can add the symbol "PSEUDOSPHERE" to the end of this list as follows:

enum SYMBOL {
...
  MAP,
  PSEUDOSPHERE  // <-- new token goes here
};
    

Now we need to open up Token.cpp and add the pseudosphere to the two lookup tables:

tokenNames[ PSEUDOSPHERE ] = "pseudosphere";
...
reservedWords[ "pseudosphere" ] = PSEUDOSPHERE;
    

Now, finally, we can add the new primitive to Parser.{h,cpp}. The best way to go about this is to look at how existing primitives are handled (sphere, box, etc.) and copy that code. It should also be noted that in order to parse a new kind of primitive, you should add dispatching for that primitive to the parseScene, parseTransformableElement, and parseGeometry functions, which is fairly self-explanatory.

Now, suppose you want to add a new attribute to a material. You would start by making the same modifications to the Token class as above. In Parser.cpp, however, you would find that the only function you actually need to modify is parseMaterial(); depending on the type of parameter, a call to parseScalarMaterialParameter() or parseVec3fMaterialParameter() should do the trick.