GPU 3D Printing Slicer for DLP/Jetting using PySLM

Anycubic Digital Light Projection (DLP) 3D Printer System used for Slicing
Anycubic DLP 3D Printer System

Digital Light Projector (DLP) 3D Printers are an exceptionally productive technique for producing highly detailed (30<๐m) parts at high speeds at minimal costs.The CLIP process is a further enhancement in build speeds.

Briefly, the DLP process is similar to Stereolithography (SLA). It cures a vat of UV curable polymer material above a flexible transparent PTFE membrane. Instead of a single exposure (UV laser) into the resin, a monochrome LCD screen is used to mask the UV exposure source underneath. A greyscale bitmap image is used for each layer. Typically for most systems, after exposing the layer (1.5-3 s), the upper build-platform retracts, and mechanically pulls the cured layer away from the flexible membrane and the process is repeated. Surprisingly simple, but effective in cost and the production speed.

Additionally, bitmap based approaches are used amongst Material Jetting (MJ) technologies predominantly used within our research group CfAM, at Nottingham. Both DLP and Material Jetting offer high resolution between 30-100 ๐m both in the XY slice plane dependent on the printer, and for Inkjet downwards of 1-10 ๐m layer thickness depending on the choice of ink loading. Accordingly, these high resolutions are demanding to print. I came accustomed to using these printers in our CfAM lab at Nottingham on a recent project. The affordability of these printers is genuinely remarkable, owing to their mechanical simplicity.

Based on a previous post back in 2016 by Dr Matt Keeter, this is an excellent reference to an approach using WebGL implementation. Their post introduced the method, but the approach was obscured by its WebGL based implementation. Frutstratingly, I never came across an implementation for use in a research environment. These appraoches are most likely used in the free slicer software provided for desktop DLP 3D Printers.

DLP 3D Printer - Anycubic Mono 4k - Nottingham Lab
Anycubic Mono 4k DLP Printer at the University of Nottingham’s CfAM Lab loaded with a composite ink. 2022.

Interestingly, this approach can also be extended for generating 3D voxel models, by applying the project across multiple directions. However, the reliability of such method for non-manifold meshes would likely be limiting.

Method for Bitmap Slicing

The method is similar and use the same infrastructure to that used in the previous post for performing height map ray projection. Likewise, to provide a cross-platform compatibility, the use of Vispy and OpenGL 2.0 GLSL shaders are utilised within a single script. As such, the resolution of the output is limited to the maximum framebuffer size supported by the GPU driver on the system.

The approach for generating slices relies on having a connected watertight with surface triangles normals correctly orientated (fixable natively using Trimesh). The approach uses a combination of Stencil buffers integrated natively in GPU hardware.

By choosing an appropriate Z-clipping plane for the camera, the Stencil buffer is used to keep and discard rasterised triangles with the z-clipping range based on the Z-order. In order to determine if the fragments rendered are inside or outside the mesh. The render pipeline uses three passes:

  • Pass 1: stencil buffer increments on front facing fragments
  • Pass 2: stencil buffer decrements on back facing fragments
  • Pass 3: discard fragments where the stencil buffer is zero

During all the render passes, GL Depth tests are turned off. Typically in 3D Programs, triangles that are obscured from view of the 3D camera, or hidden behind other triangles are culled and the fragment is discarded prior to rendering . In this method, depth testing is turned off. The full approach is detailed further in the excerpt below inside the on_draw call.

def on_draw(self, event):

    with self._fbo:
        # Set the GL state
        gloo.set_state(blend=False, stencil_test=True, depth_test=False, polygon_offset_fill=False, cull_face=False)

        # Set the size of the framebuffer to fit the geometry with the correct aspect ratio
        gloo.set_viewport(0, 0, self._visSize[0], self._visSize[1])
        gloo.set_clear_stencil(0)
        gloo.set_clear_color((0.0, 0.0, 0.0, 0.0))
        
        # Clear the framebuffer
        gloo.clear()

        self.program['bounds'] = self.bbox[0,2], self.bbox[1,2]
        self.program['aspect'] = self.physical_size[1] /  self.physical_size[0]
        
        # The position of the slice position passed to the GLSL shader
        self.program['frac'] = self._z * 2.0 

        # Draw twice, adding and subtracting values in the stencil buffer

        # Render Pass 1 (Increment Stencil Buffers)
        gloo.set_stencil_func('always', 0, 0xff)
        gloo.set_stencil_op('keep', 'keep', 'incr', 'back')
        gloo.set_stencil_op('keep', 'keep', 'keep', 'front')
        self.program.draw('triangles', self.filled_buf)

        # Render Pass 2 (Decrease Stencil Buffers)
        gloo.set_stencil_op('keep', 'keep', 'decr', 'front')
        gloo.set_stencil_op('keep', 'keep', 'keep', 'back')
        self.program.draw('triangles', self.filled_buf)

        # Clear only the color buffer
        gloo.clear(color=True, depth=False, stencil=False)

        # Render Pass 3
        gloo.set_stencil_func('notequal', 0, 0xff)
        gloo.set_stencil_op('keep', 'keep', 'keep')
        self.program.draw('triangles', self.filled_buf)

        # Store the final framebuffer 
        self.rgb = _screenshot((0, 0, self._visSize[0], self._visSize[1]))

The GLSL shaders are not particularly interesting. Focus should be given to the Vertex shader, rather than the Fragment shader. This Vertex shader processes vertices of the mesh and applies the Model View Projection (MVP) transformation matrix onto the input mesh. The MVP matrix is chosen to scale the entire geometry so that it fits within the Z-clipping range of Z = -1 to +1, and is within the scope of rendering into Stencil buffer whilst using the 3D Orthographic Camera. Finally, the model is transformed based on a fractional range (0-1) to obtain the required Z-slicing plane. An epsilon value is provided for round-off purposes.

uniform   mat4 u_model; // Model transform matrix
uniform   mat4 u_view;
uniform   mat4 u_projection;

uniform  vec2 bounds; // Z bounds
uniform  float frac;  // Z fraction (0 to 1)
uniform  float aspect; // Aspect ratio

attribute vec3 a_position;

#define EPSILON 0.001

void main() {

    vec3 pos = a_position;
    
    // Ensure the bottom of the part is positioned to z=0 using the bottom bounding box
    pos.z -= bounds[0];
    
    // Scale the so that it fits within the clipping range (-1.0 < z < 1.0)
    pos.z *= -2.0/(bounds[1]-bounds[0]);
    
    // Adjust the position of  the verticies 
    pos.z -= frac;  
    gl_Position = u_projection * u_view * u_model * vec4(pos, 1.0);
    gl_Position.z += 1.0 - EPSILON;
}

The remainder of the script sets up the infrastructure for Vispy. This is performed within the initialisation call for the script. This methods sets up the correct OpenGL state, viewport size including the use of an off-screen render and specific selection of a separate Stencil framebuffer used to render onto. Both the vertex and fragment shaders are compiled and the transformation matrix is generated based on an Orthographic projection sized to the bounding box of the geometry.


    # Window Size
    shape = int(self._visSize[1]), int(self._visSize[0])

    # Create the render texture used by default in the pipeline
    self._rendertex = gloo.Texture2D((shape + (4,)), format='rgba', internalformat='rgba32f')
    
    # These are not used but are for reference
    #self._colorBuffer = gloo.RenderBuffer(self.shape, format='color')
    #self._depthRenderBuffer = gloo.RenderBuffer(shape, format='depth')

    # Create the stencil buffer (8 bit component)
    self._stencilRenderBuffer = gloo.RenderBuffer(shape, format='stencil')
    self._stencilRenderBuffer.resize(shape, format=gloo.gl.GL_STENCIL_INDEX8)

    # Create FBO, attach the color buffer and depth buffer
    self._fbo = gloo.FrameBuffer(self._rendertex)
    self._fbo.stencil_buffer=self._stencilRenderBuffer

    # Set the size of the view port based on the size of the window (the bounding box)
    gloo.set_viewport(0, 0, self.physical_size[0], self.physical_size[1])
    gloo.set_viewport(0, 0, self._visSize[0], self._visSize[1])

    # Create the initial orthographic view projection transformation based on the bounding box of the geometry
    self.projection = ortho(self.bbox[1, 0], self.bbox[0, 0], self.bbox[1, 1], self.bbox[0, 1], 2, 40)
    # Identity matrix
    self.model = np.eye(4, dtype=np.float32)

     # Set MVP variables for shaders
    self.program['u_projection'] = self.projection
    self.program['u_model'] = self.model
    self.program['u_view'] = self.view

Other operations are processing and the Trimesh and correctly transformed into the correct position:

The script was applied to a porous aerofoil structure with an XY resolution of 20 ยตm that was used previously on an Anycubic DLP system. Below is an example cross-section taken using this approach. Notice the high resolution

GPU 3D Printing Slicer used on an aerofoil structure

Conclusions

The overall approach may have a limited use by itself. Generally, the need to bespoke high resolution slices are limited at this stage. For reference, the full excerpt of the script is temporarily located here. In future, I will consider including this as another option within PySLM.

The source code can be found below or on GitHub: