Sampling 3D Smoke Density with sampler3D in GLSL
Sampling 3D volumetric smoke density in GLSL involves using a
sampler3D uniform representing a voxel grid to retrieve
continuous scalar density values along a ray. By mapping world-space
coordinates to normalized 3D texture coordinates (vec3 in
the range [0.0, 1.0]) and using hardware linear filtering,
shaders can smoothly reconstruct volumetric fields inside a raymarching
loop. This guide details how to set up the uniform, map coordinate
spaces, query densities using the built-in texture()
function, and handle edge cases for realistic smoke rendering.
Defining the sampler3D Uniform
In GLSL, a 3D texture is declared via the sampler3D data
type. Smoke density values are typically stored as single-channel
floating-point data (e.g., GL_R16F or GL_R8 in
OpenGL), meaning the density value is extracted from the red channel
(.r):
#version 330 core
uniform sampler3D u_SmokeDensityTexture;
uniform vec3 u_VolumeMin; // World-space minimum corner of the bounding box
uniform vec3 u_VolumeMax; // World-space maximum corner of the bounding boxCoordinate Normalization (World to UVW Space)
The texture() lookup function requires texture
coordinates in normalized UVW space, ranging from 0.0 to
1.0 across each axis. When raymarching through a bounding
volume in world space, you must convert the current sampling point
pos to texture space:
vec3 worldToTexCoord(vec3 worldPos) {
return (worldPos - u_VolumeMin) / (u_VolumeMax - u_VolumeMin);
}If the sample position falls outside the [0.0, 1.0]
range, hardware sampling modes dictate the behavior. To prevent smoke
from repeating or bleeding at the boundaries, configure the OpenGL
texture wrapping to GL_CLAMP_TO_BORDER with a border color
of zero density (0.0).
The GLSL Lookup Call
Inside the fragment shader, density lookups are executed with the
overloaded texture() function, which takes the
sampler3D and a vec3 coordinate:
vec3 uvw = worldToTexCoord(currentRayPosition);
float density = texture(u_SmokeDensityTexture, uvw).r;Hardware Trilinear Interpolation
Because smoke requires smooth transitions between discrete voxel
boundaries, the texture sampler should be configured on the CPU side
with GL_TEXTURE_MIN_FILTER and
GL_TEXTURE_MAG_FILTER set to GL_LINEAR. When
queried via GLSL, the GPU automatically performs trilinear
filtering—interpolating across all eight adjacent voxels around the
sample coordinate—producing continuous gradients without visible voxel
stepping.
Raymarching Implementation
To evaluate smoke density across a volume, lookups are integrated into a standard raymarching loop:
out vec4 FragColor;
void main() {
vec3 rayOrigin = ...; // Camera or ray start point
vec3 rayDir = ...; // Normalized ray direction
// Determine entry and exit points along the ray with the volume AABB
float tNear, tFar;
intersectBoundingBox(rayOrigin, rayDir, u_VolumeMin, u_VolumeMax, tNear, tFar);
float stepSize = 0.05;
float accumulatedDensity = 0.0;
float transmittance = 1.0;
float absorption = 0.5;
for (float t = tNear; t < tFar; t += stepSize) {
vec3 samplePos = rayOrigin + rayDir * t;
vec3 uvw = worldToTexCoord(samplePos);
// Fetch density from the 3D texture
float density = texture(u_SmokeDensityTexture, uvw).r;
if (density > 0.0) {
// Beer-Lambert law for light attenuation
transmittance *= exp(-density * stepSize * absorption);
accumulatedDensity += density * stepSize;
// Early exit if opaque
if (transmittance < 0.01) {
break;
}
}
}
FragColor = vec4(vec3(accumulatedDensity), 1.0 - transmittance);
}Optimization Techniques
- Empty Space Skipping: Check low-resolution mipmaps or bounding spheres to jump past void areas before initiating dense sample loops.
- Jittering: Offset the starting ray position along
rayDirusing a dither texture or screen-space pseudo-random noise to turn regular sampling banding artifacts into less perceptible noise.