In our previous post we put a very
simple Lisp interpreter together that was capable of some very basic arithmetic.
In today’s update, we’ll introduce variable definitions into our Lisp interpreter, allowing users to define and
retrieve values persistently. This required a number of structural changes to support mutable environments, error
handling, and function lookups.
All the changes here will set us up to do much more sophisticated things in our system.
If you’re following along, you can find the implementation for this article here.
Mutable Environment
In order to store variables in our environment, we need to make it mutable. This way we can store new values in the
environment as we define them.
Env was originally a pure Map:
typeEnv=MapStringLispVal
This meant that variable bindings were immutable and couldn’t persist across expressions.
We changed Env to an IORef (Map String LispVal), making it mutable:
typeEnv=IORef(MapStringLispVal)
We added nullEnv to create an empty environment:
nullEnv::IOEnvnullEnv=newIORefMap.empty
Why?
This allows variables to persist across expressions.
Future changes (like set! for modifying variables) require mutability.
IORef enables safe concurrent updates in a controlled manner.
REPL update
Now we need to update our REPL at the top level to be able to use this mutable state.
Previously, our REPL was just using the primitiveEnv value.
main::IO()main=doputStrLn"Welcome to Mini Lisp (Haskell)"replprimitiveEnv
We now pass it in as a value. Note that the underlying types have changed.
main::IO()main=doenv<-primitiveEnv-- Create a new environmentputStrLn"Welcome to Mini Lisp (Haskell)"replenv
Why?
The REPL now uses a mutable environment (primitiveEnv).
This ensures variables persist across expressions instead of resetting each time.
Variable Definition
We introduced defineVar to allow defining variables:
Lisp has long been a favorite language for those interested in metaprogramming, functional programming, and
symbolic computation. Writing your own Lisp interpreter is one of the best ways to deepen your understanding of
programming languages. In this series, we’ll build a Lisp interpreter from scratch in Haskell, a language that lends
itself well to this task due to its strong type system and functional nature.
In this first post, we’ll cover:
Defining Lisp’s syntax and core data structures
Writing a simple parser for Lisp expressions
Implementing an evaluator for basic operations
By the end of this post, you’ll have a working Lisp interpreter that can evaluate basic expressions like (+ 1 2).
If you’re following along, you can find the implementation for this article here.
Setup
We’re using Haskell to implement our Lisp interpreter, so make sure you’re installed and
ready to go.
To get started, create yourself a new project. I use stack so creating my
new list (called hlisp):
stack new hlisp
We’ll need a few dependencies to begin with. I’m adding my entire Lisp system to my library, leaving my main exe to
simply be a REPL.
In Haskell, we can represent these using a data type:
moduleExprwhereimportData.Map(Map)importqualifiedData.MapasMapimportControl.Monad.Except-- Lisp expression representationdataLispVal=AtomString|NumberInteger|BoolBool|List[LispVal]|Lambda[String]LispValEnv-- user-defined function|BuiltinFunc([LispVal]->ThrowsErrorLispVal)-- built-in functionsinstanceShowLispValwhereshow(Atomname)=nameshow(Numbern)=shownshow(BoolTrue)="#t"show(BoolFalse)="#f"show(Listxs)="("++unwords(mapshowxs)++")"show(Lambdaparamsbody_)="(lambda ("++unwordsparams++") "++showbody++")"show(BuiltinFunc_)="<builtin function>"instanceEqLispValwhere(Atoma)==(Atomb)=a==b(Numbera)==(Numberb)=a==b(Boola)==(Boolb)=a==b(Lista)==(Listb)=a==b_==_=False-- Functions and different types are not comparable-- Environment for variable storagetypeEnv=MapStringLispVal-- Error handlingdataLispError=UnboundVarString|TypeMismatchStringLispVal|BadSpecialFormStringLispVal|NotAFunctionString|NumArgsInt[LispVal]|ParserErrorStringderiving(Show)typeThrowsError=EitherLispError
This defines the core structure of Lisp expressions and introduces a simple error-handling mechanism.
We define Show and Eq explicitly on LispVal because of the Lambda and BuiltinFunc not really having naturally
expressed analogs for these type classes. The compiler complains!
LispVal allows us to define:
Atom
Number
Bool
List
Lambda
BuiltinFunc
The Env type gives us an environment to operate in keeping track of our variables.
LispError defines some high level problems that can occur, and ThrowsError is partially applied type where you’re
either going to receive the value (to complete the application), or as the type suggests - you’ll get a LispError.
Parsing Lisp Code
To evaluate Lisp code, we first need to parse input strings into our LispVal data structures. We’ll use the Parsec
library to handle parsing.
moduleParserwhereimportText.ParsecimportText.Parsec.String(Parser)importExprimportControl.MonadimportNumeric-- Parse an atom (symbol)parseAtom::ParserLispValparseAtom=dofirst<-letter<|>oneOf"!$%&|*+-/:<=>?@^_~"rest<-many(letter<|>digit<|>oneOf"!$%&|*+-/:<=>?@^_~")return$Atom(first:rest)-- Parse a numberparseNumber::ParserLispValparseNumber=Number.read<$>many1digit-- Parse booleansparseBool::ParserLispValparseBool=(string"#t">>return(BoolTrue))<|>(string"#f">>return(BoolFalse))-- Parse listsparseList::ParserLispValparseList=List<$>between(char'(')(char')')(sepByparseExprspaces)-- General parser for any expressionparseExpr::ParserLispValparseExpr=parseAtom<|>parseNumber<|>parseBool<|>parseList-- Top-level function to run parserreadExpr::String->ThrowsErrorLispValreadExprinput=caseparseparseExpr"lisp"inputofLefterr->Left$ParserError(showerr)Rightval->Rightval
With these parsers defined, we can now evaluate expressions.
Simple Evaluation
Now, we can use these types to perform some evaluations. We do need to give our interpreter some functions that it can
execute.
moduleEvalwhereimportExprimportControl.Monad.ExceptimportqualifiedData.MapasMap-- Look up variable in environmentlookupVar::Env->String->ThrowsErrorLispVallookupVarenvvar=caseMap.lookupvarenvofJustval->RightvalNothing->Left$UnboundVarvar-- Apply a function (either built-in or user-defined)apply::LispVal->[LispVal]->ThrowsErrorLispValapply(BuiltinFuncf)args=fargsapply(Lambdaparamsbodyclosure)args=iflengthparams==lengthargstheneval(Map.union(Map.fromList(zipparamsargs))closure)bodyelseLeft$NumArgs(lengthparams)argsapplynotFunc_=Left$NotAFunction(shownotFunc)-- Evaluator functioneval::Env->LispVal->ThrowsErrorLispValevalenv(Atomvar)=lookupVarenvvareval_val@(Number_)=Rightvaleval_val@(Bool_)=Rightvalevalenv(List[Atom"quote",val])=Rightvalevalenv(List(Atomfunc:args))=dofunc'<-evalenv(Atomfunc)args'<-mapM(evalenv)argsapplyfunc'args'eval_badForm=Left$BadSpecialForm"Unrecognized form"badForm-- Sample built-in functionsprimitives::[(String,LispVal)]primitives=[("+",BuiltinFuncnumericAdd),("-",BuiltinFuncnumericSub),("*",BuiltinFuncnumericMul),("/",BuiltinFuncnumericDiv)]numericAdd,numericSub,numericMul,numericDiv::[LispVal]->ThrowsErrorLispValnumericAdd[Numbera,Numberb]=Right$Number(a+b)numericAddargs=Left$TypeMismatch"Expected numbers"(Listargs)numericSub[Numbera,Numberb]=Right$Number(a-b)numericSubargs=Left$TypeMismatch"Expected numbers"(Listargs)numericMul[Numbera,Numberb]=Right$Number(a*b)numericMulargs=Left$TypeMismatch"Expected numbers"(Listargs)numericDiv[Numbera,Numberb]=ifb==0thenLeft$TypeMismatch"Division by zero"(Numberb)elseRight$Number(a`div`b)numericDivargs=Left$TypeMismatch"Expected numbers"(Listargs)-- Initialize environmentprimitiveEnv::EnvprimitiveEnv=Map.fromListprimitives
Creating a REPL
We can now tie all of this together with a REPL.
moduleMainwhereimportEvalimportParserimportExprimportControl.MonadimportSystem.IO-- REPL looprepl::Env->IO()replenv=doputStr"λ> "hFlushstdoutinput<-getLineunless(input=="exit")$docasereadExprinput>>=evalenvofLefterr->printerrRightval->printvalreplenvmain::IO()main=doputStrLn"Welcome to Mini Lisp (Haskell)"replprimitiveEnv
Formatting drives on any operating system can be a handful of instructions specific to that operating environment. In today’s post we’ll walk through the process of formatting a USB drive to FAT32, explaining each command along the way.
Identifying the Device
Before making any changes, you need to determine which device corresponds to your USB drive. The best way to do this is:
dmesg | grep da
or, for a more detailed view:
geom disk list
On FreeBSD, USB mass storage devices are typically named /dev/daX (where X is a number). If you only have one USB drive plugged in, it is likely /dev/da0.
Device naming in FreeBSD is quite uniform:
USB Drives: /dev/daX
SATA/SAS/IDE Drives: /dev/adaX
NVMe Drives: /dev/nvmeX
RAID Volumes: /dev/mfidX, /dev/raidX
Partitioning the Drive
Now that we know the device name, we need to set up a partition table and create a FAT32 partition.
Destroying Existing Partitions
If the drive has existing partitions, remove them:
gpart destroy -F /dev/da0
This ensures a clean slate.
Creating a Partition Table
We create a Master Boot Record (MBR) partition table using:
gpart create -s mbr /dev/da0
-s mbr: Specifies an MBR (Master Boot Record) partition scheme.
Other options include gpt (GUID Partition Table), which is more modern but may not be supported by all systems.
Adding a FAT32 Partition
Now, we create a FAT32 partition:
gpart add -t fat32 /dev/da0
-t fat32: Specifies the FAT32 partition type.
Other valid types include freebsd-ufs (FreeBSD UFS), freebsd-swap (swap partition), freebsd-zfs (ZFS), and linux-data (Linux filesystem).
After running this command, the new partition should be created as /dev/da0s1.
Formatting the Partition as FAT32
To format the partition, we use newfs_msdos:
newfs_msdos -L DISKNAME -F 32 /dev/da0s1
-L DISKNAME: Assigns a label to the volume.
-F 32: Specifies FAT32.
/dev/da0s1: The newly created partition.
Why /dev/da0s1 instead of /dev/da0?
When using MBR, partitions are numbered starting from s1 (slice 1), meaning that the first partition on da0 becomes da0s1. Using /dev/da0 would format the entire disk, not just a partition.
Wrapping Up
At this point, your USB drive is formatted as FAT32 and ready to use. You can mount it manually if needed:
Rendering realistic 3D environments is more than just defining surfaces—atmospheric effects like fog, mist, and light
scattering add a layer of depth and realism that makes a scene feel immersive. In this post, we’ll explore volumetric
fog and how we can implement it in our ray-marched Mandelbulb fractal shader.
What is Volumetric Fog?
Volumetric fog is an effect that simulates light scattering through a medium, such as:
Mist over a landscape
Dense fog hiding distant objects
Hazy light beams filtering through an object
Unlike simple screen-space fog, volumetric fog interacts with geometry, light, and depth, making it appear more
natural. In our case, we’ll use it to create a soft, atmospheric effect around our Mandelbulb fractal.
How Does It Work?
Volumetric fog in ray marching is achieved by stepping through the scene and accumulating fog density based on
distance. This is done using:
Exponential Fog – A basic formula that fades objects into the fog over distance.
Light Scattering – Simulates god rays by accumulating light along the ray path.
Procedural Noise Fog – Uses random noise to create a more natural, rolling mist effect.
We’ll build each of these effects step by step, expanding on our existing Mandelbulb shader to enhance its atmosphere.
If you haven’t seen them already, suggested reading are the previous articles in this series:
We will start with the following code, which is our phong shaded, lit, mandelbulb with the camera spinning around it.
floatmandelbulbSDF(vec3pos){vec3z=pos;floatdr=1.0;floatr;constintiterations=8;constfloatpower=8.0;for(inti=0;i<iterations;i++){r=length(z);if(r>2.0)break;floattheta=acos(z.z/r);floatphi=atan(z.y,z.x);floatzr=pow(r,power-1.0);dr=zr*power*dr+1.0;zr*=r;theta*=power;phi*=power;z=zr*vec3(sin(theta)*cos(phi),sin(theta)*sin(phi),cos(theta))+pos;}return0.5*log(r)*r/dr;}vec3getNormal(vec3p){vec2e=vec2(0.001,0.0);returnnormalize(vec3(mandelbulbSDF(p+e.xyy)-mandelbulbSDF(p-e.xyy),mandelbulbSDF(p+e.yxy)-mandelbulbSDF(p-e.yxy),mandelbulbSDF(p+e.yyx)-mandelbulbSDF(p-e.yyx)));}// Basic Phong shadingvec3phongLighting(vec3p,vec3viewDir){vec3normal=getNormal(p);// Light settingsvec3lightPos=vec3(2.0,2.0,-2.0);vec3lightDir=normalize(lightPos-p);vec3ambient=vec3(0.1);// Ambient light// Diffuse lightingfloatdiff=max(dot(normal,lightDir),0.0);// Specular highlightvec3reflectDir=reflect(-lightDir,normal);floatspec=pow(max(dot(viewDir,reflectDir),0.0),16.0);// Shininess factorreturnambient+diff*vec3(1.0,0.8,0.6)+spec*vec3(1.0);// Final color}// Soft Shadows (traces a secondary ray to detect occlusion)floatsoftShadow(vec3ro,vec3rd){floatres=1.0;floatt=0.02;// Small starting stepfor(inti=0;i<24;i++){floatd=mandelbulbSDF(ro+rd*t);if(d<0.001)return0.0;// Fully in shadowres=min(res,10.0*d/t);// Soft transitiont+=d;}returnres;}voidmainImage(outvec4fragColor,invec2fragCoord){vec2uv=(fragCoord-0.5*iResolution.xy)/iResolution.y;// Rotating Camerafloatangle=iTime*0.5;vec3rayOrigin=vec3(3.0*cos(angle),0.0,3.0*sin(angle));vec3target=vec3(0.0);vec3forward=normalize(target-rayOrigin);vec3right=normalize(cross(vec3(0,1,0),forward));vec3up=cross(forward,right);vec3rayDir=normalize(forward+uv.x*right+uv.y*up);// Ray marchingfloattotalDistance=0.0;constintmaxSteps=100;constfloatminDist=0.001;constfloatmaxDist=10.0;vec3hitPoint;for(inti=0;i<maxSteps;i++){hitPoint=rayOrigin+rayDir*totalDistance;floatdist=mandelbulbSDF(hitPoint);if(dist<minDist)break;if(totalDistance>maxDist)break;totalDistance+=dist;}// Compute lighting only if we hit the fractalvec3color;if(totalDistance<maxDist){vec3viewDir=normalize(rayOrigin-hitPoint);vec3baseLight=phongLighting(hitPoint,viewDir);floatshadow=softShadow(hitPoint,normalize(vec3(2.0,2.0,-2.0)));color=baseLight*shadow;// Apply shadows}else{color=vec3(0.1,0.1,0.2);// Background color}fragColor=vec4(color,1.0);}
Depth-based Blending
To create a realistic sense of depth, we can use depth-based blending to gradually fade objects into the fog as they
move further away from the camera. This simulates how light scatters in the atmosphere, making distant objects appear
less distinct.
In ray marching, we calculate fog intensity using exponential depth functions like:
where distance is how far along the ray we’ve traveled, and densityFactor controls how quickly objects fade into
fog.
By blending our object’s color with the fog color based on this function, we achieve a smooth atmospheric fade effect.
Let’s implement it in our shader.
voidmainImage(outvec4fragColor,invec2fragCoord){vec2uv=(fragCoord-0.5*iResolution.xy)/iResolution.y;// Rotating Camerafloatangle=iTime*0.5;vec3rayOrigin=vec3(3.0*cos(angle),0.0,3.0*sin(angle));vec3target=vec3(0.0);vec3forward=normalize(target-rayOrigin);vec3right=normalize(cross(vec3(0,1,0),forward));vec3up=cross(forward,right);vec3rayDir=normalize(forward+uv.x*right+uv.y*up);// Ray marchingfloattotalDistance=0.0;constintmaxSteps=100;constfloatminDist=0.001;constfloatmaxDist=10.0;vec3hitPoint;for(inti=0;i<maxSteps;i++){hitPoint=rayOrigin+rayDir*totalDistance;floatdist=mandelbulbSDF(hitPoint);if(dist<minDist)break;if(totalDistance>maxDist)break;totalDistance+=dist;}// Compute lighting only if we hit the fractalvec3color;if(totalDistance<maxDist){vec3viewDir=normalize(rayOrigin-hitPoint);vec3baseLight=phongLighting(hitPoint,viewDir);floatshadow=softShadow(hitPoint,normalize(vec3(2.0,2.0,-2.0)));color=baseLight*shadow;}else{color=vec3(0.1,0.1,0.2);// Background color}// Apply depth-based exponential fogfloatfogAmount=1.0-exp(-totalDistance*0.15);color=mix(color,vec3(0.5,0.6,0.7),fogAmount);fragColor=vec4(color,1.0);}
Once this is running, you should see some fog appear to obscure our Mandelbulb:
Light Scattering
When light passes through a medium like fog, dust, or mist, it doesn’t just stop—it scatters in different directions,
creating beautiful effects like god rays or a soft glow around objects. This is known as volumetric light scattering.
In ray marching, we can approximate this effect by tracing secondary rays through the scene and accumulating light
contribution along the path. The more dense the medium (or the more surfaces the ray encounters), the stronger the
scattering effect. A simplified formula for this accumulation looks like:
You can see the god rays through the centre of our fractal:
With this code we’ve:
Shot a secondary ray into the scene which accumulates scattered light
The denser the fractal, the more light it scatters
density += 0.02 controls the intensity of the god rays
Noise-based Fog
Real-world fog isn’t uniform—it swirls, shifts, and forms dense or sparse patches. To create a more natural effect, we
can use procedural noise to simulate rolling mist or dynamic fog layers.
Instead of applying a constant fog density at every point, we introduce random variations using a noise function:
\(\text{fogDensity}(p)\) determines the fog’s thickness at position \(p\).
\(\text{baseDensity}\) is the overall fog intensity.
\(\text{noise}(p)\) generates small-scale variations to make fog look natural.
By sampling noise along the ray, we can create wispy, uneven fog that behaves more like mist or smoke, enhancing the
realism of our scene. Let’s implement this effect next.
We’ll add procedural noise to simulate smoke or rolling mist.
After these modifications, you should start to see the fog moving as we rotate:
The final version of this shader can be found here.
Conclusion
By adding volumetric effects to our ray-marched Mandelbulb, we’ve taken our scene from a simple fractal to a rich,
immersive environment.
These techniques not only enhance the visual depth of our scene but also provide a foundation for more advanced
effects like clouds, smoke, fire, or atmospheric light absorption.
Ray tracing is known for producing stunning reflections, we can achieve the same effect using ray
marching. In this post, we’ll walk through a classic two-sphere reflective scene, but instead of traditional ray
tracing, we’ll ray march our way to stunning reflections.
The first step is defining a scene with two spheres and a ground plane. In ray marching, objects are defined using
signed distance functions (SDFs). Our scene SDF is just a combination of smaller SDFs.
SDFs
The SDF for a sphere gives us the distance from any point to the surface of the sphere:
Now we trace a ray through our scene using ray marching.
vec3rayMarch(vec3rayOrigin,vec3rayDir,intmaxSteps,floatmaxDist){floattotalDistance=0.0;vec3hitPoint;for(inti=0;i<maxSteps;i++){hitPoint=rayOrigin+rayDir*totalDistance;floatdist=sceneSDF(hitPoint);if(dist<0.001)break;// Close enough to surfaceif(totalDistance>maxDist)returnvec3(0.5,0.7,1.0);// Sky colortotalDistance+=dist;}returnhitPoint;// Return the hit location}
Surface Normals
For lighting and reflections, we need surface normals. These are estimated using small offsets in each direction:
voidmainImage(outvec4fragColor,invec2fragCoord){vec2uv=(fragCoord-0.5*iResolution.xy)/iResolution.y;// Camera Setupvec3rayOrigin=vec3(0,0,-5);vec3rayDir=normalize(vec3(uv,1.0));// Perform Ray Marchingvec3hitPoint=rayMarch(rayOrigin,rayDir,100,10.0);// If we hit an object, apply shadingvec3color;if(hitPoint!=vec3(0.5,0.7,1.0)){vec3viewDir=normalize(rayOrigin-hitPoint);vec3baseLight=phongLighting(hitPoint,viewDir);vec3reflection=computeReflection(hitPoint,rayDir);color=mix(baseLight,reflection,0.5);// Blend reflections}else{color=vec3(0.5,0.7,1.0);// Sky color}fragColor=vec4(color,1.0);}
Running this shader, you should see two very reflective spheres reflecting each other.
Conclusion
With just a few functions, we’ve recreated a classic ray tracing scene using ray marching. This technique allows
us to:
Render reflective surfaces without traditional ray tracing
Generate soft shadows using SDF normals
Extend the method for refraction and more complex materials