Rendering Dense Laser Beams with Vertex Pulling
AI GENERATED CONTENT FOR TESTING Creating a good-looking laser effect in a 3D game can be surprisingly difficult. If you just draw a simple colored line or a long, skinny cylinder, it often looks flat, lifeless, and boring. It doesn't feel like a beam of raw, unstable energy.
For a recent project, I wanted a laser that felt like a dense, shimmering beam of energy. The solution was to treat the laser not as a single object, but as a massive particle system with thousands of tiny, glowing sprites. The best part? We can generate and render all of it almost entirely on the GPU, making it incredibly fast. 🚀
This post will walk you through the core techniques, even if you're new to graphics programming. We'll cover:
- How to set up the data on the C++ (CPU) side.
- The main trick: **Vertex Pulling**, where we create geometry from nothing in the vertex shader.
- Making our flat particles always face the camera, a technique called **Billboarding**.
- Adding a shimmering effect with a simple and cheap GPU hash function.
The Blueprint: What We Tell the GPU
In graphics programming, the CPU (your game's code) acts like a manager, and the GPU is a massive workforce of thousands of tiny processors. The manager's job is to prepare work, bundle it up, and send it to the workforce to execute all at once. Our goal is to do as much preparation as possible upfront, so we can send one big job to the GPU and let it run wild.
First, we define the "blueprint" for a laser beam in C++. This struct holds all the parameters we need to describe one laser effect.
// C++ Configuration for one laser
struct ParticleSystemConfiguration
{
glm::vec3 start; // Where the laser begins in 3D space
glm::vec3 end; // Where it ends
f32 density; // How many particles per meter? Higher means a denser beam.
f32 spread; // How much can particles stray from the center line? This gives the beam thickness.
f32 scale; // How big is each individual particle?
glm::vec4 color; // The color of the particles
};
With this configuration, we generate the actual particle data on the CPU. We create a list of particles, where each one has a random position along the beam's central line, plus a random offset based on the `spread`. This creates a cylindrical cloud of points between `start` and `end`.
This data for each individual particle is then packed into a simple struct, ready to be sent to the GPU.
// The data for a single particle, packed tightly for the GPU
struct LaserParticleInfo
{
f32 position_x;
f32 position_y;
f32 position_z;
f32 scale;
u32 color; // Color is packed into a single 32-bit integer for efficiency
};
// ... In our C++ setup ...
// This one giant buffer will hold ALL particles for ALL active lasers.
gfx::vulkan::WriteOnlyBuffer<LaserParticleInfo> particles_buffer;
We upload this massive array of `LaserParticleInfo` structs into a special kind of buffer on the GPU called a **Storage Buffer**. You can think of a storage buffer like a giant spreadsheet that the GPU can read from at any time. This is the only data we need to send.
The Main Trick: Making Particles Appear from Data
Normally, to draw something, you send a list of vertices (points in space) to the GPU. For a cube, you'd send 36 vertices. For a complex character, you might send 50,000. For our laser with potentially millions of particles, sending all those vertices would be incredibly slow.
Instead, we use a technique called **Vertex Pulling**. We send **zero** vertices. Instead, we just tell the GPU: "Hey, I want you to draw 6,000,000 vertices. Good luck!" The vertex shader, a small program that runs on the GPU for every single one of those conceptual vertices, uses a built-in counter called `gl_VertexIndex` to figure out what to do.
Since each particle is a 2D square (a "quad"), and each quad is made of two triangles, we need 6 vertices per particle. The first few lines of our vertex shader use `gl_VertexIndex` to identify which particle it should be working on.
// GLSL Code in laser.vert
void main()
{
// Which particle am I? For every 6 vertices, we move to the next particle.
// e.g., vertices 0-5 are for particle 0, 6-11 are for particle 1, etc.
const u32 particle_number = gl_VertexIndex / 6;
// Which of the 6 vertices for this particle's quad are we generating?
const u32 point_within_face = gl_VertexIndex % 6;
// Now that we know our particle's ID, we "pull" its data from the giant storage buffer.
const LaserParticleInfo thisParticleInfo = in_particles.particles[particle_number];
// Get the particle's center position from the data we just fetched.
const vec3 particle_center_position =
vec3(thisParticleInfo.position_x, thisParticleInfo.position_y, thisParticleInfo.position_z);
// ... more code to generate the quad's shape ...
}
Making Flat Things Face You: Billboarding
A 2D square in a 3D world looks paper-thin from the side. We want our particles to always look like circles facing the camera. This is called **billboarding**. The trick is to build the square using the camera's own orientation vectors. The CPU sends the camera's "right" and "up" directions to the shader, which then constructs a quad that is perfectly aligned with the screen.
// GLSL Code in laser.vert (continued)
// Get camera vectors and particle scale from our data
const vec3 r = in_push_constants.camera_right.xyz * thisParticleInfo.scale;
const vec3 u = in_push_constants.camera_up.xyz * thisParticleInfo.scale;
// Define the 4 corners of the quad in world space
// Think of it as: start at the center, move left, then move up for the top-left corner.
const vec3 corner_positions[4] = vec3[4](
particle_center_position + -r + u, // Top-Left
particle_center_position + -r + -u, // Bottom-Left
particle_center_position + r + u, // Top-Right
particle_center_position + r + -u // Bottom-Right
);
// This lookup table helps us build two triangles from our four corners.
const u32 IDX_TO_VTX_TABLE[6] = u32[6](0, 1, 2, 2, 1, 3);
// Find the correct corner for this specific vertex and project it to the screen.
gl_Position = in_push_constants.mvp_matrix
* vec4(corner_positions[IDX_TO_VTX_TABLE[point_within_face]], 1.0);
The Shimmering Effect
To make the laser flicker and feel alive, we randomly hide about half the particles each frame. A true random number generator would be slow, so we use a cheap and deterministic **hash function**. The CPU calculates a new `random_seed` based on the current time and sends it to the shader. The shader combines this seed with the particle's unique ID to decide if it should be visible. Since the seed changes every frame, a different set of particles is hidden each time, creating a convincing shimmer.
// GLSL Code in laser.vert (continued)
// Mix the frame's random seed with this particle's unique number,
// hash it, and check if the result is even or odd.
const bool should_be_visible =
bool(gpu_hashU32(in_push_constants.random_seed + particle_number) % 2);
// Pass this visibility flag to the fragment shader.
out_is_visible = u32(should_be_visible);
out_color = gpu_srgbToLinear(thisParticleInfo.color);
// ... also pass out UVs for the fragment shader ...
Making it Pretty: From Squares to Circles
Our vertex shader generates square quads, but we want round, soft particles. The **Fragment Shader** handles this. This second shader program runs for every single pixel covered by our geometry. Its job is to decide the final color of that pixel.
The vertex shader passes along the 2D coordinates (`in_uv`) for each pixel within the quad. We use a little math to check if a pixel is inside a circle drawn within that square. If it's outside the circle, we `discard` it, effectively killing that pixel so it never gets drawn.
// GLSL Code in laser.frag
// These are the outputs from the Vertex Shader
layout(location = 1) in vec2 in_uv;
layout(location = 4) in flat u32 in_is_visible;
void main()
{
// Convert UVs from the [0, 1] range to a [-1, 1] range,
// making the center of the quad (0,0).
const vec2 unit_position = in_uv * 2.0 - 1.0;
// Check two things:
// 1. Is the particle supposed to be visible this frame?
// 2. Is this pixel inside a circle of radius 1? (Pythagorean theorem!)
if (in_is_visible != 0 && length(unit_position) < 1.0)
{
// If yes, draw the pixel with its color.
out_color = in_color;
}
else
{
// If no, completely discard this pixel. It won't be drawn.
discard;
}
}
And that's the whole process! By combining these GPU-centric techniques, we can render a visually complex and dynamic laser beam with minimal cost to the CPU, allowing us to have many rich effects on screen at once.