Tutorials

Drawing thick outlines in OpenGL

Drawing outlines around 3D shapes is a common feature request, be it for selected items, quest markers, or what have you. In OpenGL there’s a few ways to do it and today I’d like to share with you the two methods I’ve tried – the OpenGL2 offset normals method (left) and the OpenGL3 per-pixel shader (right). Both use a variation on stencils. Stencils are like green screens. By isolating the thing you want to keep from the rest, it’s easy to do all kinds of neat tricks.

For those of you in the know, I’m using a brute force method. Skip to final thoughts for my reasoning.


OpenGL stencils

The first method I tried went something like this:

  • Draw every mesh
  • Draw selected stuff a second time, but this time to the stencil buffers
  • Lock the stencil buffer but mask everything – only draw to the non empty parts of the stencil.
  • Draw selected stuff a third time in the original buffer, but only edge lines in the selected color.

So let’s assume, for brevity, that there’s a drawAllMeshes method that works as advertised.
Somewhere in the pipeline there’s something like

drawAllMeshes(everything);
outlineSelectedMeshes(probablyLessThanEverything);

then outlineSelectedMeshes does all the stencil magic.

gl3.glEnable(GL3.GL_STENCIL_TEST);  // enable stencil testing  
gl3.glClear(GL3.GL_STENCIL_BUFFER_BIT | GL3.GL_DEPTH_BUFFER_BIT);  // clear the buffer  
gl3.glColorMask(false,false,false,false);  // disable writing to the color buffer  
gl3.glDepthMask(true);  // enable writing to the depth buffer  
gl3.glStencilMask(0xFF);  // enable writing to the stencil buffer  
gl3.glStencilFunc(GL3.GL_ALWAYS,1,0xFF);  // always pass the stencil test and set the value to 1  
gl3.glStencilOp(GL3.GL_KEEP,GL3.GL_KEEP,GL3.GL_REPLACE);  // set the stencil value to 1 when the depth test passes  

drawAllMeshes(gl3, selectedMeshes, camera, originShift);  // draw the selected meshes.  Only now they'll go to the stencil buffer.

gl3.glStencilFunc(GL3.GL_NOTEQUAL,1,0xFF);  // pass the stencil test if the value is not 1  
gl3.glStencilMask(0x00);  // disable writing to the stencil buffer  
gl3.glDepthMask(false);  // disable writing to the depth buffer  
gl3.glColorMask(true,true,true,true);  // enable writing to the color buffer 
gl3.glStencilOp(GL3.GL_KEEP,GL3.GL_KEEP,GL3.GL_KEEP);  // keep the stencil value  
gl3.glDisable(GL3.GL_CULL_FACE);  // draw both sides of the outline

gl3.glLineWidth(outlineThickness);  // set the line thickness
gl3.glPolygonMode(GL3.GL_FRONT_AND_BACK,GL3.GL_LINE);  // draw only lines

// outlineShader is an instance of ShaderProgram which gives me all kinds of convenience methods.
outlineShader.use(gl3);  
outlineShader.setMatrix4d(gl3, "viewMatrix", camera.getViewMatrix());  
outlineShader.setMatrix4d(gl3, "projectionMatrix", camera.getChosenProjectionMatrix(canvasWidth, canvasHeight));  
outlineShader.setColor(gl3, "outlineColor", Color.GREEN);  

// render the selected set with thick lines.
// drawAllMeshes would use the default shader, so I do it manually here
// to control the shader choice.
for(var mesh : selectedMeshes) {  
    outlineShader.setMatrix4d(gl3,"modelMatrix",mesh.getMatrix());  
    mesh.render(gl3);
}

gl3.glPolygonMode(GL3.GL_FRONT_AND_BACK,GL3.GL_FILL);  // reset shapes
gl3.glEnable(GL3.GL_CULL_FACE);  // restore settings
gl3.glDepthMask(true);  // restore settings
gl3.glLineWidth(1);  // restore settings
gl3.glStencilFunc(GL3.GL_ALWAYS,1,0xFF);  // turn off stencil testing  
gl3.glStencilOp(GL3.GL_KEEP, GL3.GL_KEEP, GL3.GL_REPLACE);  
gl3.glDisable(GL3.GL_STENCIL_TEST);

The outline shader v1

outline.vert was doing almost nothing.

#version 330 core  

layout(location = 0) in vec3 aPosition;  
layout(location = 1) in vec3 aNormal;  
layout(location = 2) in vec4 aColor;  
layout(location = 3) in vec2 aTexture;  

uniform mat4 projectionMatrix;  
uniform mat4 viewMatrix;  
uniform mat4 modelMatrix;

void main() {  
    vec3 offsetPosition = aPosition + aNormal * outlineSize;  
    vec4 worldPose = modelMatrix * vec4(offsetPosition, 1.0);  
    gl_Position = projectionMatrix * viewMatrix * worldPose;  
}

and outline.frag was doing truly nothing.

#version 330 core
out vec4 fragColor;  
uniform vec4 outlineColor;  // the outline color

void main() {  
    fragColor = outlineColor;
}

The effect on a basic cube looks like this:

Note that OpenGL also doesn’t give a way to adjust line end caps.

OpenGL 3 per pixel shaders

The OpenGL3 version required more setup but less work at run time. The process is:

  • Draw every mesh
  • Draw a second time to a stencil buffer FBO
  • Use the FBO as a reference texture (not the technical name)
  • Draw a rectangle over the whole screen.
  • The shader color every pixel in the rectangle near (but not on) the stencil.

Full screen rectangle

I’m sorely tempted to say “eh, I have a Mesh wrapper class, you don’t need these details”… but in the interest of being thorough, here’s the whole rectangle setup.

private final int [] rectangleVAO[1];
private final int [] rectangleVBO[1];

private void generateFullscreenQuad(GL3 gl3) {  
    float v = 1.0f;  // make smaller to check position on screen
    float[] quadVertices = {  
        // positions  // texcoords  
        -v, -v, 0f,   0f, 0f, // bottom-left  
         v, -v, 0f,   1f, 0f, // bottom-right  
        -v,  v, 0f,   0f, 1f, // top-left  
         v,  v, 0f,   1f, 1f  // top-right  
     };

    gl3.glGenVertexArrays(1, rectangleVAO, 0);  // make vao
    gl3.glBindVertexArray(VAO[0]);  // use vao
    gl3.glGenBuffers(2, rectangleVBO, 0);  // make vbos
    gl3.glBindBuffer(GL3.GL_ARRAY_BUFFER, rectangleVBO[0]);    // use vbos
    gl3.glBufferData(GL3.GL_ARRAY_BUFFER, quadVertices.length * Float.BYTES, FloatBuffer.wrap(quadVertices), GL3.GL_STATIC_DRAW);  // fill vbos
    gl3.glBindVertexArray(0);  // stop using vao
}

Frame buffer object

Normally the video card draws into an offscreen back buffer and at the right time swaps the back buffer and the front buffer. This way you don’t see all triangles being drawn one by one. We can create our own Frame buffer object (FBO) as canvas-sized textures. (The canvas is the whole drawing area, which might not be the entire window.) The video card can draw into the FBO of our choosing, and then we can do stuff with it. I couldn’t find an easy way to access the built-in stencil buffer so I did it this way.

// class parameters  
private int outlineThickness = 5;  
private final int [] stencilFBO = new int[1];
private final int [] stencilTexture = new int [1];

// width and height should be the canvas dimensions.
// every time the canvas changes size this should be called again.
private void setupStencilFramebuffer(GL3 gl3, int width, int height) {  
    deleteStencilBuffer(gl3);  
    // Create FBO if not already created  
    gl3.glGenFramebuffers(1, stencilFBO, 0);  
    // Create stencil texture if not created  
    gl3.glGenTextures(1, stencilTexture, 0);  

    gl3.glBindTexture(GL3.GL_TEXTURE_2D, stencilTexture[0]);  
    // create with a single 8-bit red channel
    gl3.glTexImage2D(GL3.GL_TEXTURE_2D, 0, GL3.GL_R8, width, height, 0, GL3.GL_RED, GL3.GL_UNSIGNED_BYTE, null);  
    gl3.glTexParameteri(GL3.GL_TEXTURE_2D, GL3.GL_TEXTURE_MIN_FILTER, GL3.GL_NEAREST);  
    gl3.glTexParameteri(GL3.GL_TEXTURE_2D, GL3.GL_TEXTURE_MAG_FILTER, GL3.GL_NEAREST);  
    gl3.glTexParameteri(GL3.GL_TEXTURE_2D, GL3.GL_TEXTURE_WRAP_S, GL3.GL_CLAMP_TO_EDGE);  
    gl3.glTexParameteri(GL3.GL_TEXTURE_2D, GL3.GL_TEXTURE_WRAP_T, GL3.GL_CLAMP_TO_EDGE);  

    // Bind the FBO and attach the stencil texture  
    gl3.glBindFramebuffer(GL3.GL_FRAMEBUFFER, stencilFBO[0]);  
    gl3.glFramebufferTexture2D(GL3.GL_FRAMEBUFFER, GL3.GL_COLOR_ATTACHMENT0, GL3.GL_TEXTURE_2D, stencilTexture[0], 0);  
    gl3.glDrawBuffer(GL3.GL_COLOR_ATTACHMENT0);  

    // Check FBO status  
    int status = gl3.glCheckFramebufferStatus(GL3.GL_FRAMEBUFFER);  
    if (status != GL3.GL_FRAMEBUFFER_COMPLETE) {  
        throw new RuntimeException("Failed to setup stencil framebuffer: " + status);  
    }  

    // Unbind FBO  
    gl3.glBindFramebuffer(GL3.GL_FRAMEBUFFER, 0);  
}

// called when the canvas changes size or it is disposed.
private void deleteStencilBuffer(GL3 gl3) {  
    if(stencilFBO[0]!=-1) {  
        gl3.glDeleteFramebuffers(1, stencilFBO,0);  
        stencilFBO[0] = -1;  
    }  
    if(stencilTexture[0]!=-1) {  
        gl3.glDeleteTextures(1, stencilTexture,0);  
        stencilTexture[0] = -1;  
    }  
}

Testing the stencil buffer

// Step 1: Render the stencil into an offscreen texture using the FBO  
gl3.glBindFramebuffer(GL3.GL_FRAMEBUFFER, stencilFBO[0]);  
gl3.glViewport(0, 0, canvasWidth, canvasHeight);  
// Clear stencil texture  
gl3.glClearColor(0,0,0,0);  
gl3.glClear(GL3.GL_COLOR_BUFFER_BIT | GL3.GL_DEPTH_BUFFER_BIT);

// Please forgive my custom mesh shader here.
// Draw everything without lighting in flat white.
meshShader.use(gl3);  
meshShader.setMatrix4d(gl3, "viewMatrix", camera.getViewMatrix(originShift));  
meshShader.setMatrix4d(gl3, "projectionMatrix", camera.getChosenProjectionMatrix(canvasWidth, canvasHeight));  
meshShader.set1i(gl3, "useVertexColor", 0);  
meshShader.set1i(gl3, "useLighting", 0);  
meshShader.setColor(gl3,"diffuseColor",Color.WHITE);

for(var mesh : selectedMeshes) {  
    meshShader.setMatrix4d(gl3,"modelMatrix",mesh.getMatrix());
    mesh.render(gl3);
}

// resume editing the color buffer, do not change the depth mask or the stencil buffer.  
gl3.glBindFramebuffer(GL3.GL_FRAMEBUFFER, 0);  

// Step 2: Render outlines using the stencil texture  
gl3.glActiveTexture(GL3.GL_TEXTURE0);  
gl3.glBindTexture(GL3.GL_TEXTURE_2D, stencilTexture[0]);  

captureTextureData(gl3,canvasWidth,canvasHeight);

captureTextureData is used to write stencilTexture to a BufferedImage, and then I can use a breakpoint to view the stencil buffer.

Of course this only works if something there is a selected mesh!
Also… it draws upside down because the screen Y axis is reverse from a BufferedImage y axis.

Drawing the quad

Once the stencil buffer looked good it was time to disable the capture and draw the quad on screen.

//captureTextureData(gl3,canvasWidth,canvasHeight);  

outlineShader.use(gl3);  
outlineShader.set1i(gl3, "stencilTexture", 0); // Texture unit 0  
outlineShader.set2f(gl3, "textureSize", canvasWidth, canvasHeight);  
outlineShader.setColor(gl3, "outlineColor", Color.GREEN);  
outlineShader.set1f(gl3, "outlineSize", outlineThickness);  

// Render the quad with the stencil texture to the screen  
gl3.glDisable(GL3.GL_CULL_FACE);  
fullScreenQuad.render(gl3);  
gl3.glEnable(GL3.GL_CULL_FACE);

All we need is…

Testing a new outline shader

outline.vert v2

#version 330 core  

layout(location = 0) in vec3 aPosition;  
layout(location = 1) in vec2 aTexture;  

void main() {  
    gl_Position = vec4(position, 1.0);  
}

outline.frag v2

#version 330 core  

uniform vec4 outlineColor = vec4(0.0, 1.0, 0.0, 1.0);  
uniform float outlineSize = 1.0;  
uniform vec2 canvasSize;  // Size of the texture/screen  

out vec4 finalColor;  // Output fragment color  

// Sampler for the stencil texture (or depth-stencil data)  
uniform sampler2D stencilTexture;  

// the screen and the stencilTexture have dimensions from (-1,-1) to (1,1)  
// the canvasSize, outlineSize, and gl_FragCoord are in pixels from (0,0) to (width,height)  
void main() {  
    // Size of a fragment in texture coordinates  
    vec2 texelSize = 1.0 / canvasSize;  
    // Current pixel position in the stencil texture  
    vec2 textureCoord = gl_FragCoord.xy / canvasSize;  
    vec4 stencilValue = texture(stencilTexture, textureCoord);  

    // If the stencil value is not zero we're inside the stencil area so skip.  
    if (stencilValue.r > 0.0) discard;  

    int outInt = int(ceil(outlineSize));  
    float o2 = outlineSize * outlineSize;  
    // loop over all pixels within +/-outline size  
    for (int y = -outInt; y <= outInt; y++) {  
        for (int x = -outInt; x <= outInt; x++) {  
            if(x*x + y*y > o2) continue; // Skip pixels outside the circle  
            // convert pixel offset to texture coordinate offset
            vec2 offset = vec2(x, y) * texelSize;  
            // Sample the stencil texture at the offset position  
            vec4 neighbor = texture(stencilTexture, textureCoord + offset);  
            if(neighbor.r > 0.0) {  
                // We're in range, set the outline color and exit  
                finalColor = outlineColor;  
                return;  
            }  
        }  
    }  
    // If no neighboring pixels are found with a stencil value do nothing.
    //finalColor = vec4(0,0,1,1);  // make the background blue for testing
    //finalColor = vec4(0,(textureCoord.x+1)/2,(textureCoord.y+1)/2,1);
    // gradient for testing
}

Tada!

Final thoughts

The approach I took is considered “Brute force”. Ben Golus’ Jump Flood Algorithm (JFA) is the final answer for most people. It’s the hardest to do and the most efficient at run time. Presently I am not doing huge outlines that would require the run time cost savings. Also I have not seen a GLSL step-by-step… so… Perfect is the enemy of good enough.

I write 3D stuff mainly to simulate robots I want to build. The app is called Robot Overlord and it’s totally open source, so feel free to click the link and join in the latest fun. Show the world how smart you are and make a PR with working JFA.

Seriously, if you know someone learning OpenGL… make them start with 2.0. If you can choose the language for them, pick Java. It’s just so much easier and more likely to succeed. Watching my nephew do a ten chapter tutorial on Vulkan for one triangle… that’s crazy pants.

News

Factorio self-loading construction train

Factorio is my favorite build-a-base game and the Pyanadons + Alien Life mod is my latest obsession. Huge bases are a logistics challenge, and I like to move fast. A common feature of my factories is a mall where machines build the equipment to expand the rest of the factory. Logistics drones carry things between the mall providers to make sophisticated, low-quantity equipment.

360h into Pyanadons

When a new sub-factory plan is ready, the parts need to be brought to the construction site. In an efficient factory the trip is done once. As the factory grows, so does the travel time to areas where new construction can take place.

over 60s to cross on the fastest train

Solution 1: Drones everywhere

One potential solution is to extend the logistic drone service area to the entire factory. When a new sub-factory has many thousands of parts that sends many hundreds of robots on long slow journeys carrying a few parts each. It will eventually get done….

A second problem with that method is that all-factory logistic zone will break any localized logistics setups. Sometimes it’s easier to have a local dedicated logistics zone instead of a mess of belts. The belts store a lot of valuable parts in an expensive buffer and the belt spaghetti could get really ugly.

This train station received nine different expensive products and there’s only comfortably room for 4 belts (8 products). Drones delivering from the station to A and from A to B makes life easy – provided the drones stay in their lane and don’t fly off to service every other logistic request in the factory. You gotta keep ’em separated.

Solution 2: Construction train

A better way is to bring everything on a train. Even a slow moving train is faster than hundreds of bots. Did you know in 2025 it’s still faster to move a truck full of hard drives than to transmit the same amount of data over the internet?

But I also don’t want to hunt-and-peck across the mall for every part in my blueprint. I need a way to tell my logisitics drones to fill a train for me.

This train is large enough to store equipment for even my largest builds (to date). The contents of the right-side belt, the blue inserters, the train train car, and the blue requester chest… are all on a green wire. More on that in a second.

A is a constant combinator into which I’ve dropped my latest blueprint. It has a red wire that connects to B, an arithmetic combinator.

B says red wire = red (what we want) - green (what we have) and sends that to C, the requester chest. As drones deliver the request become smaller and smaller.

D unloads the requester chest onto the belt so parts can be loaded onto the train. Left to its own devices it would spit out parts at random and then each car might not be efficiently packed. Since D has five filter slots, I use five selector combinators (E) to choose the five most plentiful items in the box. This typically results in all the belt first, then the pipes, then the rail, and so on. That also means the front of the train is packed with the most plentiful items.

Finally, F waits to see “is there a train” and “is there a demand set” and “is the demand met”? and if all those conditions are true then it sends signal A, which the train can use to depart the station. It will automatically go to the next station when it is ready, which is handy when I stay at a distant place and send the train home to fetch me things.

Final thoughts

To drop a blueprint onto a combinator, open the combinator and drop the blueprint on the “add section” button. (thanks, Jules!)

Here is the blueprint to drop into your base.

I love building large systems and Factorio’s Pyanadons mod is no exception. It has clearly had an effect on the node-based no-code system I’ve been building.

Here’s an earlier version of my sushi-belt mall, prior to logistics drones.

Here’s the same base all the way back at 67h, shortly after I unlocked trains.

News

Things Marginally Clever won’t do

We will never collaborate on sponsored posts.

We have no intention of inviting guests to post.

We are not interested in your rates for improving our social media reach.

If you represent one of these groups, please get help.

News Tutorials

Friday Facts 21: Python to Makelangelo

Makelangelo a great way to tinker with algorithmic art, procedural art, generative code, and more. Python is a popular language for building generative art. To send G-code to a Makelangelo plotter over a serial connection using Python, you can use the pyserial library. Here is an example script that demonstrates how to establish the connection and send G-code commands.

First make sure you have pyserial installed.

pip install pyserial

Then, open a serial connection to a USB connected Makelangelo.

import serial
import time

def send_gcode(port, baudrate, commands):
    try:
        # Open the serial port
        with serial.Serial(port, baudrate, timeout=1) as ser:
            time.sleep(2)  # Wait for the connection to establish

            # Send each G-code command
            for command in commands:
                ser.write((command + '\n').encode('utf-8'))
                time.sleep(0.1)  # Short delay between commands
                response = ser.readline().decode('utf-8').strip()
                print(f"Response: {response}")

    except serial.SerialException as e:
        print(f"Serial error: {e}")

# Define the G-code commands to send
gcode_commands = [
    "G28",  # Home all axes
    "G1 X10 Y10 Z0 F3000",  # Move to (10, 10, 0) at 3000 mm/min
    "G1 X20 Y20 Z0 F3000",  # Move to (20, 20, 0) at 3000 mm/min
    # Add more G-code commands as needed
]

# Send G-code commands to Makelangelo plotter
send_gcode('/dev/TTY0', 250000, gcode_commands)

Explanation

  1. Import serial and time: Import necessary modules for serial communication and timing.
  2. Define send_gcode function: This function takes the serial port, baudrate, and a list of G-code commands as arguments. It opens the serial port, sends the G-code commands one by one, and prints the responses from the plotter.
  3. Open the serial port: Using a with statement to ensure the port is properly closed after use.
  4. Send commands: Iterate through the list of G-code commands, send each command, and print the response from the plotter.
  5. Define G-code commands: A list of G-code commands to be sent to the plotter.
  6. Call send_gcode: Pass the serial port, baudrate, and G-code commands to the function.

Ensure that the port (/dev/TTY0) and baudrate (250000) match your Makelangelo plotter’s configuration. Adjust the G-code commands as needed for your specific tasks.

Final thoughts

You might also be interested in related plotter projects like vpype and vsketch.

News Projects

Friday Facts 20: Java Swing Dial UX

Dial is a Java Swing component designed to create a customizable dial interface. This component allows users to interact using the mouse wheel, click-and-drag actions, or keyboard keys. It features an ActionListener to handle turn commands, making it easy to integrate into various Java applications that require a rotary input method. It can be sometimes be more intuitive than a JSlider, which cannot “roll over” back to the starting value.

Key Features

  • Mouse Wheel Interaction: Turn the dial smoothly with the mouse wheel.
  • Mouse Click+Drag: Click and drag to adjust the dial.
  • Keyboard Control: Use the +/- keys to increment or decrement the dial value.
  • Rollover: Unlike JSlider, the dial can wrap around back to the start. Great for controlling an angle value.

Basic Usage

import com.marginallyclever.dial.Dial;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class DialDemo {
    public static void main(String[] args) {
        JFrame frame = new JFrame("Dial Demo");
        Dial dial = new Dial();
        
        dial.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                System.out.println("Dial turned: " + dial.getValue());
            }
        });

        frame.add(dial);
        frame.setSize(200, 200);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setVisible(true);
    }
}

Final Thoughts

You can customize the appearance and behavior of the dial through its properties. Adjust the color, range, and initial value as needed to fit your application.

For detailed documentation, visit the GitHub repository.