concinnity-render 0.18.67

GPU-free render preparation for the Concinnity engine
Documentation
// GPU particle simulation kernel: single source for every backend.
//
// One thread per slot in the per-emitter pool. Each thread ages and integrates
// whatever particle currently occupies its slot; if the age reaches the
// lifetime the slot is marked dead. A dead slot then tries to consume one unit
// of the frame's spawn budget (atomically) and respawns with a fresh velocity
// sampled inside a cone of half-angle acos(spread_cos) around `direction`.
//
// `Particle` and `ParticleParams` arrive from the shared PARTICLE_TYPES
// fragment, which the render half (`particle.slang`) splices too, so the pool
// this kernel writes and the pool that pass reads have one declaration. Both
// are locked to the Rust struct by `particle_params_layout_matches_msl` in
// render_types.rs.

{PARTICLE_TYPES}

[[vk::binding(0, 0)]] RWStructuredBuffer<Particle> pool : register(u0);
// Remaining spawn budget for this dispatch, as a single counter at element 0.
[[vk::binding(1, 0)]] RWStructuredBuffer<uint> spawn_counter : register(u1);

// A host difference, not a target one: Vulkan pushes the params and DirectX
// takes them as root constants at b0 (where a bare push constant lands there),
// while the Metal encoder writes them to buffer(2).
#ifdef METAL_BINDINGS
ConstantBuffer<ParticleParams> params : register(b2);
#else
[[vk::push_constant]] ConstantBuffer<ParticleParams> params;
#endif

// Cheap fixed-point hash to a unit float in [0, 1). Mutates `state` so a thread
// needing several uncorrelated samples advances it between calls.
float prng(inout uint state)
{
    state = state * 1664525u + 1013904223u;
    // Top 24 bits of the hash become the mantissa.
    return float(state >> 8) * (1.0 / 16777216.0);
}

// Sample a unit vector inside a cone of half-angle acos(cone_cos) centred on
// `axis`. The cap is uniformly sampled in solid angle, so the spawn cloud has
// no axial bunching.
float3 sample_cone(inout uint rng, float3 axis, float cone_cos)
{
    // `u` picks a polar angle whose cosine is uniform in [cone_cos, 1].
    float u = lerp(cone_cos, 1.0, prng(rng));
    float r = sqrt(max(1.0 - u * u, 0.0));
    float phi = prng(rng) * 6.2831853;
    float3 local = float3(r * cos(phi), r * sin(phi), u);

    // Any orthonormal basis around `axis`. Picking the world axis least
    // parallel to it keeps the cross product well conditioned.
    float3 up = abs(axis.y) < 0.9 ? float3(0, 1, 0) : float3(1, 0, 0);
    float3 t = normalize(cross(up, axis));
    float3 b = cross(axis, t);
    return normalize(local.x * t + local.y * b + local.z * axis);
}

[shader("compute")]
[numthreads(64, 1, 1)]
void particle_simulate(uint3 gid : SV_DispatchThreadID)
{
    uint id = gid.x;
    if (id >= params.max_particles)
    {
        return;
    }
    Particle pt = pool[id];

    // Age the particle in this slot, if any. A lifetime of 0 flags a dead slot.
    if (pt.velocity_lifetime.w > 0.0)
    {
        pt.position_age.w += params.dt;
        if (pt.position_age.w >= pt.velocity_lifetime.w)
        {
            pt.velocity_lifetime.w = 0.0;
        }
        else
        {
            pt.velocity_lifetime.xyz =
                pt.velocity_lifetime.xyz + params.gravity_speed_max.xyz * params.dt;
            pt.position_age.xyz = pt.position_age.xyz + pt.velocity_lifetime.xyz * params.dt;
        }
    }

    // A dead slot claims one unit of the remaining budget. Adding -1 in two's
    // complement returns the pre-subtract value, so only threads that observed
    // a positive remaining count spawn; threads racing past zero see 0 or a
    // wrapped very large unsigned number.
    if (pt.velocity_lifetime.w == 0.0 && params.spawn_budget > 0u)
    {
        uint claimed;
        InterlockedAdd(spawn_counter[0], 0xFFFFFFFFu, claimed);
        if (claimed > 0u && claimed <= params.spawn_budget)
        {
            uint rng = (id * 747796405u) ^ (params.random_seed * 2891336453u);
            // Warm the RNG so adjacent threads decorrelate; the value is dropped.
            float warm = prng(rng);
            float3 dir = sample_cone(
                rng,
                normalize(params.direction_speed_min.xyz),
                params.position_spread.w);
            float speed = lerp(
                params.direction_speed_min.w, params.gravity_speed_max.w, prng(rng));
            float life = lerp(params.lifetime_min, params.lifetime_max, prng(rng));
            pt.position_age = float4(params.position_spread.xyz, 0.0);
            pt.velocity_lifetime = float4(dir * speed, max(life, 0.001));
        }
    }

    pool[id] = pt;
}