pub const PARTICLE: &str = "// GPU particle system: the render half, single source for every backend.\n//\n// The renderer keeps one persistent particle pool per emitter, simulated by\n// `particle_simulate.slang` each frame and rasterised by this vertex / fragment\n// pair as camera-facing billboards. `particle_vertex` is invoked with 4\n// vertices per particle and the pool size as the instance count: each instance\n// reads its `Particle`, derives a camera-facing axis pair from the bound view,\n// and emits one corner of the quad. Dead particles emit a degenerate quad\n// behind the near plane so they cost nothing past the vertex stage.\n//\n// The composited colour is alpha-blended into the resolved HDR target by the\n// pipeline\'s blend state (Src.A * Src + (1 - Src.A) * Dst), the same envelope\n// the projected-decal pass uses.\n//\n// `Particle` and `ParticleParams` arrive from the shared PARTICLE_TYPES\n// fragment the simulation kernel splices too, so the pool written there and the\n// pool read here have one declaration.\n//\n// METAL_BINDINGS selects the Metal host\'s constant shape, which is a host\n// difference rather than a target one: Vulkan pushes the per-emitter params and\n// binds the view as a UBO, while the Metal encoder writes both with setBytes,\n// the view at buffer(1) and the params at buffer(2). Everything else carries a\n// `[[vk::binding]]` and a `register()` on one declaration, so the Vulkan\n// descriptor sets and the Metal buffer indices both reproduce what the hosts\n// already bind.\n//\n// DXIL_ABI is the third constant shape, and the only one that has to move a\n// register: the render root signature in `directx/particle.rs` puts the view at\n// b0 and the params at b1, where the Metal buffer indices are 1 and 2. The pool\n// and the albedo need no such block -- t0 and t1 / s0 are what that root\n// signature binds and what declaration order already yields.\n\n{PARTICLE_TYPES}\n\n// Per-frame view inputs to the render pass, 96 B. Mirrors `ParticleView` in\n// each backend\'s uniforms module. The two axis vectors are float4 for the\n// reason the params\' pairs are: MSL sizes a constant-buffer float3 at 16 bytes.\nstruct ParticleView\n{\n float4x4 vp;\n // xyz = world-space camera right, the first billboard axis.\n float4 cam_right;\n // xyz = world-space camera up, the second billboard axis.\n float4 cam_up;\n};\n\n// The per-emitter pool, written by the simulation kernel and read here. Vulkan\n// binds it as a read-only SSBO in the per-emitter set; Metal binds the same\n// buffer at vertex buffer(0).\n[[vk::binding(0, 1)]] StructuredBuffer<Particle> pool : register(t0);\n\n#ifdef DXIL_ABI\nConstantBuffer<ParticleView> view : register(b0);\nConstantBuffer<ParticleParams> params : register(b1);\n#else\n[[vk::binding(0, 0)]] ConstantBuffer<ParticleView> view : register(b1);\n\n#ifdef METAL_BINDINGS\nConstantBuffer<ParticleParams> params : register(b2);\n#else\n[[vk::push_constant]] ConstantBuffer<ParticleParams> params;\n#endif\n#endif\n\n// The emitter\'s albedo. A combined sampler lowers to a texture(0) + sampler(0)\n// pair on Metal, which is what that host binds.\n[[vk::binding(1, 1)]] Sampler2D<float4> albedo;\n\n// The varyings lead deliberately: D3D packs a stage signature in declaration\n// order and links the two stages by matching semantic *and* register, so a\n// fragment reading TEXCOORD0 must find it where the vertex handed it out.\n// Metal and Vulkan are order-blind here (every varying carries an attribute or\n// an explicit location).\nstruct ParticleVertexOut\n{\n [[vk::location(0)]] float2 uv : TEXCOORD0;\n [[vk::location(1)]] float4 color : TEXCOORD1;\n // Constant across the quad, so interpolating it would only cost bandwidth.\n [[vk::location(2)]] nointerpolation float discard_flag : TEXCOORD2;\n float4 position : SV_Position;\n};\n\n[shader(\"vertex\")]\nParticleVertexOut particle_vertex(uint vid : SV_VertexID, uint iid : SV_InstanceID)\n{\n ParticleVertexOut o;\n Particle pt = pool[iid];\n\n // Dead slot -> a degenerate quad clipped behind the near plane. The\n // fragment also discards on `discard_flag`, so any pixel that still\n // rasterises (the numerical edge case at exactly w = 0) draws nothing.\n if (pt.velocity_lifetime.w <= 0.0)\n {\n o.position = float4(0.0, 0.0, -2.0, 1.0);\n o.uv = float2(0.0, 0.0);\n o.color = float4(0.0, 0.0, 0.0, 0.0);\n o.discard_flag = 1.0;\n return o;\n }\n\n float t = clamp(pt.position_age.w / pt.velocity_lifetime.w, 0.0, 1.0);\n float size = lerp(params.size_start, params.size_end, t);\n float4 color = lerp(params.color_start, params.color_end, t);\n\n // 0..3 -> (-1,-1), (+1,-1), (-1,+1), (+1,+1) for a triangle strip.\n float2 corner = float2(\n (vid & 1u) == 0u ? -1.0 : 1.0,\n (vid & 2u) == 0u ? -1.0 : 1.0);\n\n float3 right = view.cam_right.xyz * (corner.x * 0.5 * size);\n float3 up = view.cam_up.xyz * (corner.y * 0.5 * size);\n float3 world = pt.position_age.xyz + right + up;\n o.position = mul(view.vp, float4(world, 1.0));\n\n // 0..1 in each axis; V is flipped at sample time to match the rest of the\n // engine\'s textures (V = 0 at the top of the image).\n o.uv = corner * 0.5 + 0.5;\n o.color = color;\n o.discard_flag = 0.0;\n return o;\n}\n\n[shader(\"fragment\")]\nfloat4 particle_fragment(ParticleVertexOut i) : SV_Target\n{\n if (i.discard_flag > 0.5)\n {\n discard;\n }\n float2 uv = float2(i.uv.x, 1.0 - i.uv.y);\n float4 sampled = albedo.Sample(uv);\n return float4(sampled.rgb * i.color.rgb, sampled.a * i.color.a);\n}\n";Expand description
particle.slang.