(Note: the videos are heavily compressed; visual quality is significantly reduced)
I got inspired to implement “holes from any angle” after watching Acerola’s CS2 voxel smokes video. He left it as a mystery for the audience to investigate and so I’ll briefly explain my solution here; some of the comments helped give an idea where to start and also some help from LLMs.
The structure of the hole:
struct GPU_Hole
{
V2 uv_min = {}; // In case we want to sample a texture atlas.
V2 uv_max = { 1.0f, 1.0f };
V3 origin = {};
f32 time_start = 0.0f;
V3 forward = V3_FORWARD;
f32 time_to_reach_far = 0.25f;
V3 right = V3_RIGHT;
f32 time_to_stay_open = 100.0f;
V3 up = V3_UP;
f32 time_to_fade = 0.35f; // Time it takes smoke to recover.
f32 time_to_expand = 0.05f; // Time for hole to fully expand.
f32 radius = 0.5f; // Defines the scale of the hole.
f32 dst_to_far = 100.0f; // Distance from origin to farthest smoke AABB hit point.
b32 active = FALSE;
};
A texture describes the shape of the hole and is sampled in the GPU at the raymarched sample-point to modify the density at that point.
The hole is basically a rotated-box/cylinder. When raymarching, we transform the sample-point to the local space of this rotated-box/cylinder, check if the sample-point is within its bounds, then compute a uv-value that we use to sample the hole mask texture. We then use that mask value to alter the density at that sample point:
float apply_smoke_hole(float3 sample_point, float density)
{
float final_density = density;
for (uint i = 0; i < MAX_HOLES; i++) {
GPU_Hole hole = holes[i];
if (!hole.active) continue;
float3 hole_origin_to_sample_point = sample_point - hole.origin;
// We will now essentially transform the vector to local space of the hole via it's basis vectors. We _could_ upload a transform matrix from CPU, but we want to test individual components to hopefully early out.
float z = dot(hole_origin_to_sample_point, hole.forward);
if (z < 0.0f || z > hole.dst_to_far) continue; // Outside bounds.
float time_passed = time - hole.time_start;
float dst_current = (time_passed / hole.time_to_reach_far) * hole.dst_to_far;
if (z > dst_current) continue; // To respect animation of "fully penetrating".
float time_to_reach_z = (z / hole.dst_to_far) * hole.time_to_reach_far;
float time_open_at_z = time_passed - time_to_reach_z;
float radius_scale = 1.0f;
if (time_open_at_z < hole.time_to_expand) {
// Grow 0 to 1.
float t = time_open_at_z / hole.time_to_expand;
radius_scale = t;
}
else if (time_open_at_z > hole.time_to_stay_open) {
// Shrink 1 to 0.
float t = (time_open_at_z - hole.time_to_stay_open) / hole.time_to_fade;
//radius_scale = smoothstep(1.0f, 0.0f, saturate(t));
radius_scale = 1.0f - t;
}
if (radius_scale <= EPSILON) continue;
float x = dot(hole_origin_to_sample_point, hole.right);
float y = dot(hole_origin_to_sample_point, hole.up);
// Add noise to edges of hole.
float3 uvw = sample_point + float3(0, -time * 0.35f, 0);
float n = noise_texture.SampleLevel(sampler0, uvw, 0) * 2.0f - 1.0f;
float radius = hole.radius * radius_scale + n;
if (abs(x) > radius || abs(y) > radius) continue;
float2 uv = float2(x, y) / radius; // -1..1
uv = uv * 0.5f + 0.5f; // 0..1
uv.y = 1.0f - uv.y; // Invert y
uv = saturate(uv);
uv = lerp(hole.uv_min, hole.uv_max, uv); // Sub-uv
float mask = hole_mask_texture.SampleLevel(sampler0, uv, 0).r;
// Local fade.
float fade_factor = saturate((time_open_at_z - hole.time_to_stay_open) / hole.time_to_fade);
float hole_strength = mask * (1.0f - fade_factor); // mask 1 means remove density.
final_density *= (1.0f - hole_strength);
if (final_density <= EPSILON) return 0.0f;
}
return final_density;
}
In these demos, I am using the font atlas as the hole mask texture. stb_truetype was used to generate the font atlas and stbtt_packedchar to compute the uv_min and uv_max of each letter.