(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.

Resources

Acerola’s video