Core design
The BMO package is intended to provide optical simulation capabilites with as much "out-of-the-box" comfort as possible, meaning that users should not have to worry about e.g. providing the correct optical sequence and exact alignment of objects. The following five design principles are core assumptions of the underlying API:
Optical interactions are decoupled from the underlying geometry representation
Optical elements are closed volumes or must mimic as such (exceptions apply, e.g. coatings)
Elements should be easily moveable and have working interactions for most angles of incidence
Without additional knowledge, tracing is performed non-sequentially
With additional knowledge, tracing is performed sequentially
Intersect-Interact-Repeat-Loop
The first two principles will be elaborated upon in more detail in the Geometry representation section. For the latter two design decisions, the following high-level solver schematic can be used to abstract the steps that are performed when calling solve_system! with an input system and beam:
This scheme is loosely referred to as the Intersect-Interact-Repeat-Loop and consists of the following steps:
Calculate the closest Intersection between a ray/beam and the objects within the system
Calculate the optical Interaction that occurs at the surface or within the volume of the element
Attach the next part of the ray chain or beam tree
Use the new information to repeat 1.
Once this procedure has been completed, the alignment of the system or other time-dependent optical properties (e.g. the phase of a Gaussian beamlet) can be updated. A new call of solve_system! solves the beam again from its start. This is described in more detail in the section: Tracing systems.
The next sections will focus on the Intersection and Interaction steps.
Intersections
Calculating intersections between straight lines, i.e. rays, and surfaces is a central challenge for every geometrical optics simulator. This must be done with high numerical precision, since many optical effects are sensitive on the order of the wavelength of the light under consideration with respect to position and direction [26, p. 265 ff]. In order to define this mathematically or algorithmically, many different methods exist [27]. The first question is, how is the geometry of the problem defined. This topic is treated in the Geometry representation section. The second question concerns then the algorithm or equation that allows to calculate the point of intersection between a ray and the surface of the element. This function is called intersect3d and is, at its core, defined for each shape and ray:
BeamletOptics.intersect3d Method
intersect3d(shape::AbstractShape, ::AbstractRay)Defines the intersection between an AbstractShape and an AbstractRay, must return an Intersection or nothing. The default behavior for concrete shapes and rays is to log a warning (No intersect3d method defined for: ...) and to indicate no intersection, that is nothing, which will inform the tracing algorithm to stop. Refer to the Intersection documentation for more information on the return type value.
Regardless of the underlying concrete implementation, each call of intersect3d must return nothing or the following type:
BeamletOptics.Intersection Type
Intersection{T}Stores data calculated by the intersect3d method.
Fields:
object: aNullablereference to theAbstractObjectthat has been hit. Theintersect3dmethods of shapes leave itnothing, the solver sets itshape: aNullablereference to theAbstractShapeof theobjectthat has been hit. Provide it: composite objects and the check whether the rays of a beamlet hit the same shape identify the hit part by this referencet: length of the ray parametrization in [m]n: normal vector at the point of intersectionnext: the next hit of the ray at the same point, ornothing, seecoincident_hits
Coincident hits
Where surfaces touch, e.g. the faces of a cemented doublet or a coating on its substrate, the ray hits several of them within the tolerance get_coincidence_tolerance() of the closest hit. The intersection of the ray then is a chain of all of them, linked by next. A shape or object type returns a single hit from intersect3d; the chain is built by intersect3d(parts, ray) and the solver.
Since an optical element can consist of multiple joint shapes, the return type must store which specific part of the object was hit: the shape of the Intersection is that part, and the solver compares and looks up the hit part by this reference, e.g. to decide whether the rays of a beamlet hit the same shape. The object of the Intersection is set by the solver, the intersect3d method of a shape leaves it nothing.
The closest hit of a ray is found on two levels: the solver searches the system for the objects that the ray can reach, and each object it reaches computes its own hit.
The search, shown below, goes through children(system), the top-level objects and groups of the system, one at a time and keeps the closest hit found so far. Once there is a hit, every part whose bounding sphere the ray enters only beyond that hit (plus get_coincidence_tolerance(), so that touching surfaces are still found) is skipped, see Bounding sphere pretest. A group is entered only if the ray reaches its sphere; its objects are then searched in the same way. trace_all starts without a hit. trace_one starts with the hit of the hinted object, so it can skip more parts from the start, see Hints. A StaticSystem has no groups while tracing, since it flattens them when it is built.
Where surfaces touch, e.g. the faces of a cemented doublet, the ray hits several of them at the same point. merge_hits keeps the hits within get_coincidence_tolerance() of the closest one as a chain, see Coincident hits.
Each object that the search reaches computes its hit with intersect3d(object, ray), shown below. The default method tests the bounding sphere of the object first and then its shape or parts. A component can define its own method instead, as the plate beamsplitters do, e.g. the RectangularPlateBeamsplitter. Once there is a hit, the search skips the object by this sphere, so the sphere must enclose every hit of such a method as well, see Bounding sphere pretest.
The shape backend is selected by dispatch on the type of the shape, see Geometry representation.
Coincident hits
Touching elements, e.g. the two lenses of a cemented doublet or a coating on its substrate, share a surface. A ray that reaches it hits the surfaces of both within the accuracy of the intersection algorithms. Instead of picking one of them, the solver keeps all hits within get_coincidence_tolerance() (1 nm by default) of the closest hit as a chain, linked by the next field of the BeamletOptics.Intersection:
BeamletOptics.coincident_hits Function
coincident_hits(intersection::Intersection)Iterates over the hits of a ray at the same point: the intersection itself, followed by the other surfaces that the ray hits within get_coincidence_tolerance(), e.g. the touching faces of a cemented doublet. Each hit is an Intersection with its own object, shape and normal. A ray that hits a single surface has a single hit.
BeamletOptics.merge_hits Function
merge_hits(a, b, tol = get_coincidence_tolerance())Merges the hits a and b of the same ray, each nothing or a chain of coincident_hits, into one chain. Its first hit is the closer of a and b, a if both lie equally far. It holds all hits of both chains within tol of the first hit, the others are dropped. The chains are linked anew, hence a and b must not be used afterwards.
Hence the set of coincident hits does not depend on the order of the objects in the system or on a BeamletOptics.Hint. Their order does: the closest hit comes first, a BeamletOptics.Hint moves the hit of its shape to the front, and the solver interacts with the first hit only. Among the hits of separate objects without a hint, which comes first is decided by rounding errors or, for equal path lengths, by the order of the objects in the system.
Bounding sphere pretest
Since the tracing is non-sequential, every ray is tested against every shape of the system, and most of these tests are misses. A shape can therefore provide a sphere that encloses it. A ray that does not pass through this sphere is a miss without a call of intersect3d. This is optional: a shape without a sphere is tested as described above.
BeamletOptics.bounding_sphere_of Method
bounding_sphere_of(shape::AbstractShape)
bounding_sphere_of(object::AbstractObject)
bounding_sphere_of(group::AbstractObjectGroup)Computes a sphere that encloses the shape, object or group in its current pose and returns it as a SingleBoundingSphere or MultiBoundingSphere, or returns NoBoundingSphere() if there is none.
For a shape, the method is an optional part of the AbstractShape interface. With the default NoBoundingSphere() every ray is tested via intersect3d. With a sphere, a solve tests a ray against the sphere first and skips the shape if it misses, which pays off for shapes with a costly intersect3d. The sphere is computed once per solve_system! and not stored in the shape, hence the method may take some time, e.g. look at all vertices of a mesh. The table holds spheres of floating-point shapes only: for other number types, e.g. dual numbers under ForwardDiff, the shape is tested without a sphere.
The sphere must enclose every point of the shape, for all its parameters: a hit outside of the sphere is lost without a warning. It should also be tight, since a ray that hits the sphere is tested as before. A safety margin is not needed, the solver adds its own tolerance. Draw the result with render!(ax, sphere) to look at it.
# a cylinder of the radius `r` and the height `h`, with the origin at the center of its base
function BeamletOptics.bounding_sphere_of(c::MyCylinder)
return BeamletOptics.SingleBoundingSphere(c, Point3(0, c.h / 2, 0), sqrt(c.r^2 + (c.h / 2)^2))
endFor an object, the result follows from its shapes, see AbstractShapeTrait: the sphere of the shape of a SingleShape object, and for a MultiShape object or an object group the MultiBoundingSphere that encloses the spheres of all its parts, or NoBoundingSphere() if one part has none. The solver skips an object whose sphere the ray misses or enters only beyond a hit found already, also if the object has an intersect3d method of its own. Hence the sphere of an object must enclose every hit of its intersect3d. A SingleShape object whose own intersect3d hits points outside of its shape, e.g. an unbounded plane, returns NoBoundingSphere(); the sphere of a MultiShape object is always the one around its parts, hence its own intersect3d must hit its parts only:
BeamletOptics.bounding_sphere_of(::MyPlane) = BeamletOptics.NoBoundingSphere()All Signed Distance Functions (SDFs) and Meshes have a bounding sphere. An object of several shapes and an object group have the sphere around the spheres of their parts, such that a ray that misses it is tested against none of them. The table holds spheres of floating-point shapes only: for other number types, e.g. dual numbers under ForwardDiff, the pretest is skipped and the shapes are tested directly.
The spheres are not stored in the shapes or objects. solve_system! computes them once per call, for the poses that the objects have at that time, via bounding_sphere_of(x) into a table, which the intersection code reads per ray via bounding_sphere_of(table, x). The solve of a beam group computes the table once for all its beams, and the solves of its beams keep it instead of computing it again. Outside of a solve there is no table, i.e. a direct call of intersect3d(object, ray) tests the object without a sphere.
The table lives in the task-local storage of the solving task, not in a ScopedValue, because the intersection code reads it for every object and ray and this read is several times cheaper. A task that is spawned within a solve does not see the table of its parent. It passes the table on by calling BeamletOptics.with_bounding_spheres(f, table), as the solve of a beam group does. The system must not change while a solve runs, since the spheres are those of the poses at the start. The types in the sphere code are asserted, and the methods for the parts of an object are kept separate from those for a shape, because inference would otherwise widen their argument types and the shapes would be tested by code compiled for abstract types.
BeamletOptics.bounding_sphere_of Method
bounding_sphere_of(table::BoundingSphereTable, x)
bounding_sphere_of(::Nothing, x)Returns the sphere that the table holds for x, or NoBoundingSphere() if there is none. Nothing is computed.
BeamletOptics.SingleBoundingSphere Type
SingleBoundingSphere{T} <: AbstractBoundingSphereA sphere that encloses one shape, see bounding_sphere_of.
Fields
pos: the center in world coordinates [m]radius: the radius [m]
BeamletOptics.MultiBoundingSphere Type
MultiBoundingSphere{T} <: AbstractBoundingSphereA sphere that encloses several bounding spheres, i.e. all parts of a MultiShape object or all objects of an object group, see bounding_sphere_of. A ray that misses it hits none of the parts, hence a solve tests it before the parts. It is tested against a ray like a SingleBoundingSphere and differs only in what it states: the sphere of several things.
Fields
pos: the center in world coordinates [m]radius: the radius [m]
BeamletOptics.NoBoundingSphere Type
States that a shape or object has no bounding sphere. It is then tested against every ray via intersect3d, see bounding_sphere_of.
It has no center and no radius: position and radius throw an ArgumentError. Code that needs them handles NoBoundingSphere by a method of its own, or tests for it first.
The spheres of a system can be shown via render!(ax, BeamletOptics.bounding_sphere_of(x)) for one sphere or the show_bounding_sphere keyword of render!.
Interactions
Optical interactions are performed after the point of intersection has been determined. The interact3d interface allows users to implement algorithms that calculate or try to mimic optical effects. The fidelity of the algorithm is effectively only limited by the amount of information that can be passed into the interact3d interface. The method is defined as follows:
BeamletOptics.interact3d Method
interact3d(::AbstractSystem, object::AbstractObject, ::AbstractBeam, ::AbstractRay)Defines the optical interaction between an incoming/outgoing beam/ray of light and an optical element, must return an AbstractInteraction or nothing. The default behavior is that no interaction occurs, i.e. return of nothing, which should stop the system tracing procedure. Refer to the AbstractInteraction typedocs for more information on the return type value.
A MultiShape object receives this call for whichever of its parts was hit. Its method finds that part, e.g. via shape(intersection(ray)), and hands the beam to it, see CubeBeamsplitter. Parts that do not interact as one belong in an ObjectGroup or another AbstractObjectGroup instead, where each part interacts on its own.
As with the intersect3d method, a predefined return type must be provided in order to make the solve_system! interface work. The AbstractInteraction is used in order to create "building blocks" from which the output beam is constructed.
BeamletOptics.AbstractInteraction Type
AbstractInteractionDescribes how an AbstractBeam and an AbstractObject interact with each other. This data type stores information from the interact3d function and provides it to the solver. The solver can use this data to extend the AbstractBeam.
Implementation reqs.
Subtypes of AbstractInteraction must implement the following:
Fields
hint: a nullableHintfor the solver. The field is required, since the solver reads it for every interaction, but it may benothing
Beam data
It is required that concrete implementations of this type provide some form of data on how to extend the beam. For instance, refer to theBeamInteraction and GaussianBeamletInteraction.
An object whose interact3d method stores data in the object, e.g. a detector its hits, also implements initialize!, which discards this data before a new solve.
The interact3d return type holds one new beam segment per interaction, which the solver appends to the current beam. Further beams, e.g. the reflected part at a beamsplitter, are attached to the beam as children by the component, and the solver queues them after the current beam, see Tracing systems. The developer needs to take into account that after e.g. a lens surface air-to-glass interaction, the solver "forgets" that the next logical step is to immediately test against the lens again, since the most likely step will be the refraction at the glass-to-air surface. In order to alleviate this issue, the Hint type can be used.
Re-emitting components
Some components do not continue a beam where it ended, but hand it to another solver and emit the result elsewhere, e.g. a fiber that takes the light at its input facet and releases it at its output facet. Their interact3d method samples the incoming beamlet segment as a field, lets the other solver compute the field at the exit and starts new beamlets there. The exchange format is the PlaneField of OpticsBase.jl (see OpticsBase interoperability):
function BeamletOptics.interact3d(::AbstractSystem, fiber::MyFiber, agb::AstigmaticGaussianBeamlet, id::Int)
f = PlaneField(agb, id; size, spacing, origin, axes) # the segment that hit the fiber, on its input plane
g = other_solver(f) # PlaneField at the exit, with the full phase
BeamletOptics.relaunch!(agb, [GaussianModeDecomposition(g)])
return nothing
endThe new beamlets are attached with BeamletOptics.relaunch! instead of children!. Children added by children! continue the parent: its length, optical path and reference plane are counted on. A re-emitted beamlet already carries the full phase in its amplitude and starts at another place, so it must count its optical path from its own start.
BeamletOptics.relaunch! Function
relaunch!(beam::B, new_beams::AbstractVector{B}) where {B <: AbstractBeam}Attaches new_beams as children of beam that start their own optical reference: they are traced and removed together with beam, but their length, optical path and phase are counted from their own start instead of being continued from beam. The new beams carry the full phase in their amplitude.
Use it in the interact3d of a component that re-emits light elsewhere, e.g. the output end of a fiber or a solver coupled through a field: the light leaves at another place than where beam ended, so the geometric continuation of children! (parent length, reference plane of a beamlet) does not apply.
The new_beams replace any children that beam already has. A beam that the component keeps and attaches again is solved from its first ray, like every beam.
PlaneField(beamlet, id; ...) is for components without a detector, e.g. in the interact3d of a component that hands the incoming beamlet to another solver: the segment that just hit the component is id. It builds a single hit of the segment and passes it to PlaneField(hits; ...), which also takes the hits of several segments.
OpticsBase.PlaneField Method
OpticsBase.PlaneField(beam::BeamletOptics.AstigmaticGaussianBeamlet, id::Integer;
size::NTuple{2,Int}, spacing::NTuple{2,Real}, origin, axes, progress = false)
OpticsBase.PlaneField(beam::BeamletOptics.GaussianBeamlet, id::Integer; kwargs...)Samples the field of segment id of a traced beamlet beam on a plane, as PlaneField(detector; ...) does for the beamlets that hit a detector.
size and spacing are as for PlaneField(detector; ...); origin (in [m]) and axes (columns u, v, n) place the plane and have no default. The field is that of the segment's straight continuation through the plane (no further interaction), including the optical path from the source (parents included), and follows the conventions of PlaneField(detector; ...): no projection factor, H from the beamlet direction, a scalar GaussianBeamlet polarized along u, R = Inf, n the refractive index of the segment.
Both beamlet models can be sampled on any of their segments, also within a medium.
Warning
The scalar GaussianBeamlet takes no Fresnel losses at surfaces: within a medium its field carries the full power of the beamlet, while that of an AstigmaticGaussianBeamlet is reduced by the transmission of each surface it passed.
OpticsBase.PlaneField Method
OpticsBase.PlaneField(hits::AbstractVector{<:Union{GaussianBeamletHit, AstigmaticGaussianBeamletHit}};
size::NTuple{2,Int}, spacing::NTuple{2,Real}, origin, axes, progress = false)Samples the coherent field of a vector of beamlet hits on a plane. Build the hits with BeamletOptics.GaussianBeamletHit(beam, id) or BeamletOptics.AstigmaticGaussianBeamletHit(beam, id) (public, not exported).
Arguments
hits: allGaussianBeamletHits or allAstigmaticGaussianBeamletHits, with a shared vacuum wavelength and refractive index.size(keyword, required): number of samples(nx, ny)alongu,v.spacing(keyword, required): sample spacings(Δu, Δv)in [m].origin(keyword, required): center of the plane in global coordinates in [m].axes(keyword, required):3×3matrix with columnsu,v,n.progress(keyword): show a progress bar. Default:false.
The field follows the conventions of PlaneField(detector; ...).
Throws an ArgumentError if hits is empty, if the hits are not beamlet hits, or if they do not share wavelength and refractive index.
Hints
As mentioned in the previous section, the BeamletOptics.Hint interface allows developers to manipulate the non-sequential solver algorithm into testing against a specific component and shape during the next cycle of the Intersect-Interact-Repeat-Loop. The hinted object is tested first, and the objects whose bounding spheres lie beyond its hit are skipped. The hint does not change which surface is hit: a closer hit of another object wins, and among the Coincident hits at the closest point the hint only puts the hinted shape first.
BeamletOptics.Hint Type
HintA Hint can be passed as part of an AbstractInteraction and will inform the tracing algorithm about which AbstractObject in the AbstractSystem will be hit next.
Info
The Hint does not need to result in a guaranteed Intersection, and it does not change which surface is hit: the hinted object is tested first, the objects beyond its hit are skipped, and a closer hit of another object wins. Among the surfaces hit at the same point, e.g. the touching faces of a cemented doublet, the hinted shape comes first.
Fields
object: the object that might or will be intersected next. The solver callsinteract3dfor this object, hence for a part of aMultiShapeobject it is the composite itself, not the partshape: the underlying shape that will be intersected next, i.e.shape(object), relevant for multi-shape objects
Hint(object) uses shape(object), which is a tuple for a MultiShape object and fails. Pass the hit part explicitly there, Hint(object, part), with the composite as object and the shape that will be hit as part.
The main reason for this is the intersection ambiguity encountered at interfaces between air-tight component interfaces, e.g. Plate beamsplitters or cemented Doublet lenses. This is caused by the fact that for a ray with a starting point at this interface, technically both shapes are being "touched" at the same time. Additional program logic considering the ray direction of propagation can not always resolve this ambiguity. Therefore the task of providing additional information to the solver via the Hint interface is placed as a burden on the developer.
The diagram below shows this for the CubeBeamsplitter, whose interact3d method finds the hit part by identity, hands the beam to it and hints the coating; the DoubletLens hints the other lens in the same way.
The solver picks the interact3d method by the object of the hint, not by its shape. For a BeamletOptics.MultiShape component the hint therefore names the composite as object and the part that is hit next as shape, as Hint(cbs, shape(cbs.coating)) does above; Hint(object) alone fails for such an object, since shape(object) is a tuple.
Tip
The Hint interface is needed most for multishape objects with joint surfaces, but any component can use it: a single lens hints itself when a ray enters it or is totally internally reflected, since the next intersection lies on the same lens.
Tracing logic
Tracing systems
In the initial state, it is assumed that the problem consists of objects <: AbstractObject (in a system) and a beam <: AbstractBeam with a defined starting position and direction. No additional information is provided, and the specific path of the beam is not known beforehand. Consequently, brute force tracing of the optical system is required, involving testing against each individual element to determine the trajectory of the beam.
A call of solve_system! traces the beam tree breadth-first: it traces one beam after another and queues the children that the components attach, up to the keyword depth_max of solve_system! branching levels.
BeamletOptics.trace_system! Function
trace_system!(system::AbstractSystem, beam::Beam{T}; r_max = get_default_r_max()) where {T <: Real}Trace a Beam through an optical system. The number of rays of the beam can be capped by r_max.
Tracing logic
The intersection of the last ray of the beam with any objects contained within the system is tested. If an object is hit, the optical interaction is calculated. If no interaction occurs or no further objects are hit, the tracing procedure is stopped.
Arguments
system: The optical system through which theBeamis traced.beam: TheBeamobject to be traced.r_max: Maximum number of rays of thebeam, which ends the tracing.
trace_system!(system::AbstractSystem, gauss::GaussianBeamlet{T}; r_max = get_default_r_max(), check_invariant = true) where {T <: Real}Trace a GaussianBeamlet through an optical system. The number of rays of each component beam can be capped by r_max.
Tracing logic
The chief, waist and divergence beams are traced step-by-step through the system. For each intersection after a tracing_step!, the intersections are compared. If all rays hit the same target, the optical interaction is analyzed, else the tracing stops. Tracing also stops with a warning if the Lagrange invariant of a new segment leaves get_lagrange_tolerance of its start value.
Arguments
system: The optical system through which theGaussianBeamletis traced.gauss: TheGaussianBeamletobject to be traced.r_max: Maximum number of rays of each component beam, which ends the tracing.check_invariant: enables or disables the check of the Lagrange invariant.
trace_system!(system, agb::AstigmaticGaussianBeamlet; r_max = get_default_r_max(), check_invariant = true, threshold = get_invariant_threshold())Trace an AstigmaticGaussianBeamlet through an optical system. The number of rays of each component beam can be capped by r_max. All 9 component beams (chief + 8 parabasal) are traced in lockstep: the chief ray is traced first for each intersection, followed by the auxiliary rays. All rays must hit the same shape; otherwise tracing stops.
For a Beam, trace_system! runs the loop below until the beam has r_max rays, its last ray hits nothing or interact3d returns nothing; the methods for beamlets run one tracing_step! per component ray and one interact3d for the whole beamlet per step, and also stop if the rays hit different shapes or the optical invariant check fails.
This non-sequential mode is comparatively safe in determining the "true" beam path, but will scale suboptimally in time-complexity with the amount of optical elements.
Object order
Unlike with classic, surface-based ray tracers, the order in which objects are listed in the System object vector/tuple is not considered for the purpose of tracing.
CPU and GPU support
Parallelizing the execution of a solve_system! call on the CPU is straight-forward, since the solution is independent for each beam or ray and the solver can run on multiple threads: the beams of a beam group are solved in parallel, see below. Objects that are mutated during a solve, e.g. detectors like the Detector, need special consideration when implementing them, since multiple threads might be able to access the underlying memory, leading to race conditions. Specifically, this means ensuring e.g. atomic write and read access. The Detector takes a lock for its hits and, within a beam group solve, collects them in a buffer per chunk of beams.
With respect to GPU acceleration, this is not the case. Currently, all available implementations of solve_system! are highly branching algorithms which can not be implemented in a parallelized way easily. This will most likely require a specific new subtype of the BeamletOptics.AbstractSystem with determinable sequential properties. This is not a development goal as of the writing of this section.
The beams of a group are solved in chunks, and all beams of a group share one table of bounding spheres. It belongs to a task, so each task of the threads sets it for its beams. Each chunk collects its detector hits and stores them at its end. A free task takes the next chunk, because the beams of a group differ in cost (e.g. the inner rings of a source pass a lens, the outer ones miss it), and those of similar cost lie next to each other. Four chunks per thread balance most of the load. Each further chunk costs a channel access and a flush, which shows if all beams cost the same.