Advanced Design

Surface Roughness & Scattering

Model stray light and haze caused by micro-scale surface imperfections.

IntermediateAdvanced Optical DesignNumPy backend12 min read

Introduction

This tutorial demonstrates how surface roughness and scattering can be configured on surfaces in Optiland. We will compare singlets with the following scattering properties assigned:

  • No scattering
  • Gaussian Scattering
  • Lambertian Scattering

The scattering model is defined via the Bidirectional Scattering Distribution Function, or BSDF.

Core concepts used

scatter.GaussianBSDF(sigma=0.01)
Models a smooth but imperfectly polished surface where most rays still travel in the specular direction, but some are slightly deflected.
scatter.LambertianBSDF()
Models a perfectly diffuse surface (like ground glass or white paper) that scatters light equally in all directions.
num_rays=1_000_000
To visualize scattering effectively, you need a high ray count to capture the statistical tail of the distribution.

Step-by-step build

1

Import Libraries

python
import matplotlib.pyplot as plt
 import numpy as np

 from optiland import optic, scatter
2

Define Configurable Singlet Class

Preparation

We first define a generic singlet class that accepts a bsdf as an input. In this example, we assign the scattering only to the rear surface.

python
class SingletConfigurable(optic.Optic):
 def __init__(self, bsdf):
     super().__init__()

     # add surfaces
     self.add_surface(index=0, radius=np.inf, thickness=np.inf)
     self.add_surface(
         index=1,
         thickness=7,
         radius=50,
         is_stop=True,
         material="N-SF11",
     )
     self.add_surface(index=2, thickness=50, bsdf=bsdf)  # <-- add bsdf here
     self.add_surface(index=3)

     # add aperture
     self.set_aperture(aperture_type="EPD", value=25.4)

     # add field
     self.set_field_type(field_type="angle")
     self.add_field(y=0)
     self.add_field(y=10)
     self.add_field(y=14)

     # add wavelength
     self.add_wavelength(value=0.48613270)
     self.add_wavelength(value=0.58756180, is_primary=True)
     self.add_wavelength(value=0.65627250)

     self.image_solve()  # solve for image plane
3

Add Ray Distribution Helper

Let's also define a helper function to plot a 2D distribution of rays intersection points.

python
def plot_ray_distribution(rays, bins=128):
 x = rays.x
 y = rays.y
 i = rays.i

 plt.hist2d(x, y, weights=i, bins=bins, cmap="viridis")
 plt.colorbar()
 plt.xlabel("X (mm)")
 plt.ylabel("Y (mm)")
 plt.title("2D Ray Distribution on Image Plane")
 plt.show()
4

Build Singlet Without Scattering

The first singlet we analyze will have no scattering applied. Let's first define the lens and draw it.

python
singlet_no_scatter = SingletConfigurable(bsdf=None)
5

Draw the Unscattered Singlet

python
singlet_no_scatter.draw()
Step
6

Trace 1M Rays and Plot Distribution

Let's trace 1 million random rays through the lens at the on-axis field point and look at the distribution.

python
rays = singlet_no_scatter.trace(
 Hx=0,
 Hy=0,
 wavelength=0.58756180,
 num_rays=1_000_000,
 distribution="random",
)

plot_ray_distribution(rays, bins=128)
Step
7

Apply Gaussian BSDF Scattering

As we can see, the energy is largely located at the origin on the image plane. Let's see how this is implacted when scattering is introduced.

Gaussian scattering is defined by a 2D Gaussian distribution with a user-defined sigma (std. dev.) value. The larger the value of sigma, the closer the scattering model comes to Lambertian. We define a GaussianBSDF model with a sigma value of 0.01 and generate a new singlet:

python
bsdf = scatter.GaussianBSDF(sigma=0.01)
singlet_gaussian = SingletConfigurable(bsdf=bsdf)
8

Trace Rays Through Gaussian Singlet

Again, we trace 1 million rays and view the distribution at the image plane:

python
rays = singlet_gaussian.trace(
 Hx=0,
 Hy=0,
 wavelength=0.58756180,
 num_rays=1_000_000,
 distribution="random",
)

plot_ray_distribution(rays)
Step
9

Apply Lambertian BSDF Scattering

The image size has blurred significantly in comparison to the no-scattering case. Note that the plot axis spans a larger range here as well.

Lambertian scattering implies that the surface scatters incident light uniformly in all directions. Diffuse surfaces can be considered approximately Lambertian. To model a Lambertian scatterer in Optiland, we simply define the LambertianBSDF model and pass it to our singlet:

python
bsdf = scatter.LambertianBSDF()
singlet_lambertian = SingletConfigurable(bsdf=bsdf)
10

Trace Rays Through Lambertian Singlet

python
rays = singlet_lambertian.trace(
 Hx=0,
 Hy=0,
 wavelength=0.58756180,
 num_rays=1_000_000,
 distribution="random",
)

plot_ray_distribution(rays, bins=np.linspace(-100, 100, 128))
Step
Show full code listing
python
import matplotlib.pyplot as plt
import numpy as np

from optiland import optic, scatter

class SingletConfigurable(optic.Optic):
  def __init__(self, bsdf):
      super().__init__()

      # add surfaces
      self.add_surface(index=0, radius=np.inf, thickness=np.inf)
      self.add_surface(
          index=1,
          thickness=7,
          radius=50,
          is_stop=True,
          material="N-SF11",
      )
      self.add_surface(index=2, thickness=50, bsdf=bsdf)  # <-- add bsdf here
      self.add_surface(index=3)

      # add aperture
      self.set_aperture(aperture_type="EPD", value=25.4)

      # add field
      self.set_field_type(field_type="angle")
      self.add_field(y=0)
      self.add_field(y=10)
      self.add_field(y=14)

      # add wavelength
      self.add_wavelength(value=0.48613270)
      self.add_wavelength(value=0.58756180, is_primary=True)
      self.add_wavelength(value=0.65627250)

      self.image_solve()  # solve for image plane

def plot_ray_distribution(rays, bins=128):
  x = rays.x
  y = rays.y
  i = rays.i

  plt.hist2d(x, y, weights=i, bins=bins, cmap="viridis")
  plt.colorbar()
  plt.xlabel("X (mm)")
  plt.ylabel("Y (mm)")
  plt.title("2D Ray Distribution on Image Plane")
  plt.show()

singlet_no_scatter = SingletConfigurable(bsdf=None)

singlet_no_scatter.draw()

rays = singlet_no_scatter.trace(
  Hx=0,
  Hy=0,
  wavelength=0.58756180,
  num_rays=1_000_000,
  distribution="random",
)

plot_ray_distribution(rays, bins=128)

bsdf = scatter.GaussianBSDF(sigma=0.01)
singlet_gaussian = SingletConfigurable(bsdf=bsdf)

rays = singlet_gaussian.trace(
  Hx=0,
  Hy=0,
  wavelength=0.58756180,
  num_rays=1_000_000,
  distribution="random",
)

plot_ray_distribution(rays)

bsdf = scatter.LambertianBSDF()
singlet_lambertian = SingletConfigurable(bsdf=bsdf)

rays = singlet_lambertian.trace(
  Hx=0,
  Hy=0,
  wavelength=0.58756180,
  num_rays=1_000_000,
  distribution="random",
)

plot_ray_distribution(rays, bins=np.linspace(-100, 100, 128))

Conclusions

In this case, we are plotting the image plane over a significantly larger area, from -100 mm to 100 mm. Clearly, the Lambertian scatter model has dramatically increased the spot size at the image plane.

  1. Conclusions:
  • We introduced two BSDF scatter models: Gaussian and Lambertian.
  • Scatter models can be used to model and understand the impact of manufacturing defects, such as surface roughness on optical surfaces.

Next tutorials