Ray Tracing in One Weekend... in Ada
I've been learning Ada 2012, it started out as an intellectual curiosity some time in March, and then I bought John Barne's "Programming in Ada 2012" book for a more in-depth look. A lot people don't really know hardly anything about Ada, and I don't really blame them. Until recently, resources were very scarce and there's not good comparison programs to see what Ada is like.
I did a port of Ray Tracing in One Weekend as faithfully as possible from C++ into Ada. This comparison should help people understand more of what Ada is about to satisfy their own curiosity.
Overall, the port went very smoothly, due to the conceptual similarities between C++ and Ada. There's a few things to point out, and a few rough spots with Ada which were frustrating.
Project Layout
This is the directory structure::
Many Ada projects use the GNAT ecosystem, which you can get from the Free Software Foundation or with GNAT Community Edition, released by AdaCore. ray_tracer.gpr
is akin to a CMakeLists.txt, except for the GNAT Project Manager gprbuild. Most of the Ada ecosystem assumes that tools will get run from the command line, and GPR files reference the appropriate options used by the related programs. This file is an Ada-derived domain-specific language (DSL).
Projects might create multiple GPR files, and such as one for the main program, another for building and running tests, or for example programs in libraries.
main.adb
: Main Function
Files have three parts: a context clause, declarative parts, and executable parts.
The context clause describes dependencies on library "packages", the declarative part allows declaring and defining functions and variables, and the executable start is linearly executable code.
Ada uses packages as proper modules instead of including files with a preprocessor. Libraries are brought in using with
, with the dots in the name describing the package path. Terminal
here is a child package of GNATCOLL
.
Unlike other languages, the entry procedure for an Ada program doesn't need to named main
. Files defining program entries define your main function, but all functionality must be brought in from libraries or in the declaration block of the main function.
Library "packages" follow a similar, but slightly different format than main files. Packages get split between a public interface, described in a .ads
file, while the package body is in an .adb
file. This is similar to the header/source separation in C or C++ libraries, except the compiler treats these as real entities.
Packages operate like namespaces, but also modules. Packages can even include a begin
section of initialization code to run before starting the main function, and describe startup dependencies between packages.
Expression functions help knock down verboseness
Ada 2012 adds "expression functions" where instead of a full is begin ... end
you can just wrap the expression describing the value to return in parentheses. This made the vector implementation surprisingly terse.
Semantic for types ("derived types") in Ada
The reference version uses type aliases for 3D vectors, which improves the look of code but doesn't prevent misuses of types according to their semantics.
Yes, you can prevent such mistakes in C++ through inheritance or templates with tags for types, but most people don't jump through these hoops. You can specify different semantic meanings for types in Ada with "derived types" which use the same generated function code but the compilers prevents misuse in code. These aren't just type aliases, they're new actually new types:
The Color3
and Point3
types get created with the same properties as Vec3
, but with functions which only take their respective types. Yes, you can force the conversion but it will be explicit.
Since Refract
is defined after Color3
and Point3
were defined, it isn't included as part of these types, so it can't be used.
Ada lacks perfect forwarding
One of the best and killer features of modern C++ is perfect forwarding combined with parameter pack. The gist of these features is that you can create your own functions which hand off their arguments to another function as if that second function was called directly. This is especially useful in situations where you want to, for example, imitate the interface of a constructor for a type, such as to construct an object inside a container or as part of a smart pointer.
Let's say you have a type which looks like:
You can actually call that constructor directly when making smart pointers in C++ (unique_ptr
or shared_ptr
):
You see this lack of perfect forwarding and the copy required in RT.Materials
:
One line, 30% of runtime CPU
The most innocuous and expensive line of code in the program was the F32
definition of the common floating point type used by the raytracer. The issue is the range check is defined to be the attribute range (read as "tick range"). This is really a little bit of error checking magic provided by Ada to find infinities and NaN's whenever a F32
is assigned to or used as a parameter, which obviously happens a lot in a raytracer. Disabling range checks or removing the range for production use eliminates this problem, but it demonstrates how much error checking is possible from a single line of code in Ada.