From 219eec4aa500f09255783f88c460bc7172601198 Mon Sep 17 00:00:00 2001 From: Floris Date: Thu, 10 Sep 2026 21:24:12 +0200 Subject: [PATCH 1/2] Integrated BAR's nano particle gl4 gadget into engine (default disabled) (#3191) * Nano Particles: add NanoParticleUpdate engine callin Batched, unsynced lifecycle events for nano particles, so deferred-lighting widgets can light them without polling. Nothing emits these yet; the standalone nano particle effect added in the following commit owns the batching and the sampling that decides which particles are reported. Events are passed as one flat numeric array of 13-entry records rather than a table per event, because a per-event table would dominate the cost of the callin at the rates involved. Co-Authored-By: Claude Opus 5 (1M context) * Nano Particles: add standalone nano particle effect Adds an optional nano particle effect that renders build spray as shader-generated 3D shapes with an additive halo, behind NanoParticlesGL4 (default off). Ported from BAR's gfx_nano_particles_gl4 gadget. The effect is not a projectile. Its particles have no projectile id, take no part in collision or quadfield work, are never handed to Lua as projectiles, and are not serialised; motion is analytic, so the shader reconstructs position from start/velocity/frame and the CPU only touches a particle when it homes or has to clear terrain. All of it lives in rts/Rendering/Env/NanoParticles: NanoParticleConfig every tunable, in one place NanoParticleDefs the PODs the other three share NanoParticleSystem the particle store, homing, ground clamp, LuaUI batching NanoParticleEmitter how much spray a builder produces, and reclaim bursts NanoParticleRenderer shaders, buffers, culling, draw Legacy nano spray is untouched. NanoProjectile, ProjectileDrawer and NanoPieceCache have no diff at all; when the effect is off, or no shader path is usable, emission falls through to CNanoProjectile exactly as before. The simulation cannot tell the difference either way: a work tick still polls QueryNanoPiece once and draws one synced RNG value, and everything the effect adds runs off the unsynced RNG, as nano spray already did. Beyond the shader look the effect also carries, each behind its own setting: * emission proportional to buildSpeed * buildPower rather than one particle per work tick, so a builder's spray tracks the work it is doing instead of its nano piece count, spread round-robin over its pieces * NanoParticlesHoming, particles following moving targets * NanoParticlesGroundClamp, particles routed over intervening terrain * NanoParticlesReclaimBurst, a burst when reclaiming a unit finishes, sized by metal cost and split across the builders that contributed * NanoParticlesUpdateLuaUI, the batched callin added in the previous commit The per-unit emission accumulators and the reclaim contributor tracking are owned by the emitter rather than by CBuilder/CUnit: they are unsynced presentation state, they must not be serialised, and no part of the sim needs to know they exist. No sim class gains a member. The renderer is heap-allocated behind a pointer, as the other GL-owning drawers are. A VBO's constructor calls VBO::IsSupported(), which latches the GLAD extension flags into function-local statics on its first call; constructing one before GLAD has loaded latches them all to false and silently turns every VBO in the process into a no-op. Particles show on the minimap as the legacy ones do, reusing the vertex arrays the world pass already filtered so it costs one walk and no visibility work, and filling the shared projectile minimap buffer rather than adding a draw of its own. The spawn gate is the effect's own rather than the legacy proportional one, which throttles from the first particle and so makes emission approach the budget asymptotically instead of scaling with NanoParticlesRate. Tunables are named and documented in NanoParticleConfig.h instead of being literals spread through the sources. The visual subset reaches both shader paths as uniforms, so the geometry and instanced renderers cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) * Nano Particles: changelog for the standalone effect Co-Authored-By: Claude Opus 5 (1M context) * added `Engine.FeatureSupport.nanoParticlesGL4` boolean, so games can detect that the engine has the standalone nano particle effect and retire their own Lua implementation of it. * moved changelog to: doc\pr-changelogs\3191.md * Nano Particles: added configbool: NanoParticlesTargetLostFade "Nano particles fade out and shrink when the unit they were aimed at is destroyed, cancelled, or finished, instead of flying on into nothing" (ported over this feature from the gadget as well) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../shaders/GLSL/NanoParticleFragProg.glsl | 128 +++ .../shaders/GLSL/NanoParticleGeomProg.glsl | 222 ++++ .../GLSL/NanoParticleNoGeomVertProg.glsl | 184 ++++ .../shaders/GLSL/NanoParticleVertProg.glsl | 34 + doc/pr-changelogs/3191.md | 48 + doc/site/content/changelogs/_index.markdown | 2 +- rts/Game/Game.cpp | 4 + rts/Game/UI/MiniMap.cpp | 3 + rts/Lua/LuaConstEngine.cpp | 6 +- rts/Lua/LuaHandle.cpp | 64 ++ rts/Lua/LuaHandle.h | 1 + rts/Rendering/CMakeLists.txt | 4 + rts/Rendering/Env/IWater.cpp | 3 + .../Env/NanoParticles/NanoParticleConfig.cpp | 115 ++ .../Env/NanoParticles/NanoParticleConfig.h | 289 +++++ .../Env/NanoParticles/NanoParticleDefs.h | 142 +++ .../Env/NanoParticles/NanoParticleEmitter.cpp | 335 ++++++ .../Env/NanoParticles/NanoParticleEmitter.h | 86 ++ .../NanoParticles/NanoParticleRenderer.cpp | 733 +++++++++++++ .../Env/NanoParticles/NanoParticleRenderer.h | 173 +++ .../Env/NanoParticles/NanoParticleSystem.cpp | 990 ++++++++++++++++++ .../Env/NanoParticles/NanoParticleSystem.h | 127 +++ rts/Rendering/WorldDrawer.cpp | 5 + rts/Sim/Projectiles/ProjectileHandler.cpp | 68 +- rts/Sim/Projectiles/ProjectileHandler.h | 7 +- rts/Sim/Units/Unit.cpp | 14 + rts/Sim/Units/UnitTypes/Builder.cpp | 13 +- rts/Sim/Units/UnitTypes/Builder.h | 2 +- rts/Sim/Units/UnitTypes/Factory.cpp | 7 + rts/System/EventClient.h | 2 + rts/System/EventHandler.cpp | 6 + rts/System/EventHandler.h | 4 + rts/System/Events.def | 1 + 33 files changed, 3793 insertions(+), 29 deletions(-) create mode 100644 cont/base/springcontent/shaders/GLSL/NanoParticleFragProg.glsl create mode 100644 cont/base/springcontent/shaders/GLSL/NanoParticleGeomProg.glsl create mode 100644 cont/base/springcontent/shaders/GLSL/NanoParticleNoGeomVertProg.glsl create mode 100644 cont/base/springcontent/shaders/GLSL/NanoParticleVertProg.glsl create mode 100644 doc/pr-changelogs/3191.md create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleConfig.cpp create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleConfig.h create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleDefs.h create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleEmitter.cpp create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleEmitter.h create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleRenderer.cpp create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleRenderer.h create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleSystem.cpp create mode 100644 rts/Rendering/Env/NanoParticles/NanoParticleSystem.h diff --git a/cont/base/springcontent/shaders/GLSL/NanoParticleFragProg.glsl b/cont/base/springcontent/shaders/GLSL/NanoParticleFragProg.glsl new file mode 100644 index 00000000000..8c3cd6f22d9 --- /dev/null +++ b/cont/base/springcontent/shaders/GLSL/NanoParticleFragProg.glsl @@ -0,0 +1,128 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +/* + * Shared final stage for both nano particle paths. + * + * Two kinds of fragment arrive here: the faces of the tumbling shape, and the + * camera-facing additive halo (g_isGlow). Every tunable is a uniform fed from + * NanoParticleConfig. + */ + +#version 150 compatibility + +uniform float animationFrame; +uniform vec3 cameraPos; + +uniform float showInside; +uniform float noiseAmount; +uniform float noiseSpeedPerFrame; +uniform float noiseScale; +uniform float glowIntensity; +uniform float glowFalloff; +uniform float coreBoost; +uniform float hueJitter; +uniform float whiteHotspot; +uniform float whiteHotspotThreshold; + +in vec4 g_color; +in vec3 g_normal; +in vec3 g_worldPos; +in vec3 g_localPos; +in vec3 g_noiseSeed; +in vec2 g_glowUV; +in float g_isGlow; +in float g_seed; +in float g_fade; + +out vec4 fragColor; + +const vec3 LUMA_WEIGHTS = vec3(0.2126, 0.7152, 0.0722); +/// Luma the halo tint is lifted toward, and the ceiling on that lift. +const float GLOW_TARGET_LUMA = 0.55; +const float GLOW_MAX_BOOST = 5.0; + +float hash13(vec3 value) +{ + value = fract(value * 0.1031); + value += dot(value, value.zyx + 31.32); + return fract((value.x + value.y) * value.z); +} + +float valueNoise3(vec3 value) +{ + vec3 cell = floor(value); + vec3 fraction = fract(value); + fraction = fraction * fraction * fraction * (fraction * (fraction * 6.0 - 15.0) + 10.0); + + float n000 = hash13(cell + vec3(0, 0, 0)); + float n100 = hash13(cell + vec3(1, 0, 0)); + float n010 = hash13(cell + vec3(0, 1, 0)); + float n110 = hash13(cell + vec3(1, 1, 0)); + float n001 = hash13(cell + vec3(0, 0, 1)); + float n101 = hash13(cell + vec3(1, 0, 1)); + float n011 = hash13(cell + vec3(0, 1, 1)); + float n111 = hash13(cell + vec3(1, 1, 1)); + vec4 xMix = mix(vec4(n000, n010, n001, n011), vec4(n100, n110, n101, n111), fraction.x); + vec2 yMix = mix(xMix.xz, xMix.yw, fraction.y); + return mix(yMix.x, yMix.y, fraction.z); +} + +void main() +{ + vec3 tint = vec3(1.0) + hueJitter * vec3( + sin(g_seed), + sin(g_seed + 2.094), + sin(g_seed + 4.188) + ); + + if (g_isGlow > 0.5) { + float radialDistance = length(g_glowUV); + if (radialDistance > 1.0) + discard; + + float glow = pow(clamp(1.0 - radialDistance, 0.0, 1.0), glowFalloff) * glowIntensity; + + /* The halo carries the team colour at full saturation; normalising it + * first keeps a dark team's halo as bright as a light team's. */ + vec3 glowTint = g_color.rgb / max(max(g_color.r, max(g_color.g, g_color.b)), 0.001); + float glowLuma = dot(glowTint, LUMA_WEIGHTS); + float glowBoost = min(GLOW_TARGET_LUMA / max(glowLuma, 0.001), GLOW_MAX_BOOST); + + /* Output is premultiplied (blend is ONE, ONE_MINUS_SRC_ALPHA), so the + * fade has to scale colour as well as alpha or the light never dims. */ + fragColor = vec4(glowTint * tint * (glow * glowBoost), g_color.a * glow) * g_fade; + return; + } + + vec3 normal = normalize(g_normal); + vec3 lightDirection = normalize(vec3(0.4, 1.0, 0.25)); + float directionalShade = 0.85 + 0.15 * max(dot(normal, lightDirection), 0.0); + + /* Back faces are dimmed rather than culled, so the shape reads as a + * translucent chunk instead of a flat silhouette. */ + vec3 viewDirection = normalize(cameraPos - g_worldPos); + float normalDotView = dot(normal, viewDirection); + float shade3D; + float alpha3D; + if (normalDotView >= 0.0) { + shade3D = 0.80 + 0.45 * (1.0 - normalDotView); + alpha3D = 1.0; + } else { + shade3D = 0.30 + 0.30 * (-normalDotView); + alpha3D = 0.55; + } + + float shade = mix(directionalShade, directionalShade * shade3D, showInside); + float alphaMultiplier = mix(1.0, alpha3D, showInside); + + float noiseTime = animationFrame * noiseSpeedPerFrame; + vec3 noisePosition = g_localPos * noiseScale + g_noiseSeed + vec3(noiseTime, noiseTime * 0.7, noiseTime * 1.3); + float noiseValue = valueNoise3(noisePosition); + shade *= 1.0 + noiseAmount * (noiseValue * 2.0 - 1.0); + + vec3 baseColor = g_color.rgb * tint * shade * coreBoost; + float hotspot = smoothstep(whiteHotspotThreshold, 1.0, noiseValue) * whiteHotspot; + baseColor = mix(baseColor, vec3(1.0) * max(shade, 0.6), hotspot); + + fragColor = vec4(baseColor, g_color.a * alphaMultiplier) * g_fade; +} diff --git a/cont/base/springcontent/shaders/GLSL/NanoParticleGeomProg.glsl b/cont/base/springcontent/shaders/GLSL/NanoParticleGeomProg.glsl new file mode 100644 index 00000000000..ab9aa4cf04e --- /dev/null +++ b/cont/base/springcontent/shaders/GLSL/NanoParticleGeomProg.glsl @@ -0,0 +1,222 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +/* + * Geometry-shader path, stage 2 of 3. Expands one point per particle into a + * tumbling cube plus a camera-facing additive halo. + * + * Everything that shapes the look is a uniform fed from NanoParticleConfig, so + * this file holds no tunables. The helper functions below are duplicated in + * NanoParticleNoGeomVertProg.glsl, which has to produce an identical picture; + * the engine's shader loader has no #include, so the two copies must be kept in + * step by hand. + */ + +#version 150 compatibility + +layout(points) in; +layout(triangle_strip, max_vertices = 28) out; + +uniform float animationFrame; +uniform vec3 cameraRight; +uniform vec3 cameraUp; +uniform vec4 clipPlane; + +uniform float drawRadius; +uniform float sizeVariation; +uniform float baseAlpha; +uniform float alphaVariation; +uniform float glowScale; +uniform float colorEqualize; +uniform float colorTargetLuma; +uniform float rotationRange; +uniform float rotationRatePerFrame; +/// World-space frustum planes of the active camera, same convention as CCamera::Frustum. +uniform vec4 frustumPlanes[6]; + +in vec3 v_velocity[]; +in vec3 v_lifetime[]; // x = createFrame, y = deathFrame, z = fadeFrames +in vec4 v_color[]; + +out vec4 g_color; +out vec3 g_normal; +out vec3 g_worldPos; +out vec3 g_localPos; +out vec3 g_noiseSeed; +out vec2 g_glowUV; +out float g_isGlow; +out float g_seed; +/// 1 = full strength, 0 = gone. Applied to the whole fragment, so the halo fades with the shape. +out float g_fade; +out float gl_ClipDistance[1]; + +/* Output variables are undefined after EmitVertex(), so per-particle values + * the emit helpers need have to live outside them. */ +float particleFade = 1.0; + +const float TAU = 6.2831853; +const vec3 LUMA_WEIGHTS = vec3(0.2126, 0.7152, 0.0722); + +/* Rejecting here rather than on the CPU keeps the vertex buffer stable while + * the camera moves, and this is the stage worth protecting: every surviving + * particle costs 28 emitted vertices. */ +bool outsideFrustum(vec3 center, float radius) +{ + for (int i = 0; i < 6; ++i) { + if (dot(frustumPlanes[i].xyz, center) + frustumPlanes[i].w < -radius) + return true; + } + + return false; +} + +float hash11(float value) +{ + return fract(sin(value) * 43758.5453); +} + +/* Raw team colours span a wide brightness range; without this the darker teams + * produce nano spray that is barely visible against terrain. */ +vec3 equalizeColor(vec3 color) +{ + float luma = dot(color, LUMA_WEIGHTS); + if (luma < 0.001) + return color; + + color *= pow(colorTargetLuma / luma, colorEqualize); + + float maxChannel = max(color.r, max(color.g, color.b)); + if (maxChannel > 1.0) + color /= maxChannel; + + return color; +} + +mat3 rotXYZ(vec3 angle) +{ + float cx = cos(angle.x), sx = sin(angle.x); + float cy = cos(angle.y), sy = sin(angle.y); + float cz = cos(angle.z), sz = sin(angle.z); + mat3 rotateX = mat3(1, 0, 0, 0, cx, sx, 0, -sx, cx); + mat3 rotateY = mat3(cy, 0, -sy, 0, 1, 0, sy, 0, cy); + mat3 rotateZ = mat3(cz, sz, 0, -sz, cz, 0, 0, 0, 1); + return rotateZ * rotateY * rotateX; +} + +void emitShapeVertex( + vec3 center, + vec3 worldOffset, + vec3 localPos, + vec3 normal, + vec4 color, + vec3 noiseSeed, + vec2 glowUV, + float isGlow, + float seed +) { + g_color = color; + g_normal = normal; + g_worldPos = center + worldOffset; + g_localPos = localPos; + g_noiseSeed = noiseSeed; + g_glowUV = glowUV; + g_isGlow = isGlow; + g_seed = seed; + g_fade = particleFade; + gl_Position = gl_ModelViewProjectionMatrix * vec4(g_worldPos, 1.0); + gl_ClipDistance[0] = dot(vec4(g_worldPos, 1.0), clipPlane); + EmitVertex(); +} + +void emitFace( + vec3 corner0, + vec3 corner1, + vec3 corner2, + vec3 corner3, + vec3 normal, + vec3 center, + vec4 color, + vec3 noiseSeed, + float seed +) { + emitShapeVertex(center, corner0, corner0, normal, color, noiseSeed, vec2(0.0), 0.0, seed); + emitShapeVertex(center, corner1, corner1, normal, color, noiseSeed, vec2(0.0), 0.0, seed); + emitShapeVertex(center, corner2, corner2, normal, color, noiseSeed, vec2(0.0), 0.0, seed); + emitShapeVertex(center, corner3, corner3, normal, color, noiseSeed, vec2(0.0), 0.0, seed); + EndPrimitive(); +} + +void emitGlow(vec3 center, vec4 color, float halfSize, float seed) +{ + vec3 right = cameraRight * halfSize; + vec3 up = cameraUp * halfSize; + vec3 normal = vec3(0.0, 1.0, 0.0); + vec3 noiseSeed = vec3(0.0); + + emitShapeVertex(center, -right - up, vec3(0.0), normal, color, noiseSeed, vec2(-1.0, -1.0), 1.0, seed); + emitShapeVertex(center, right - up, vec3(0.0), normal, color, noiseSeed, vec2( 1.0, -1.0), 1.0, seed); + emitShapeVertex(center, -right + up, vec3(0.0), normal, color, noiseSeed, vec2(-1.0, 1.0), 1.0, seed); + emitShapeVertex(center, right + up, vec3(0.0), normal, color, noiseSeed, vec2( 1.0, 1.0), 1.0, seed); + EndPrimitive(); +} + +void main() +{ + vec3 center = gl_in[0].gl_Position.xyz; + float createFrame = v_lifetime[0].x; + float deathFrame = v_lifetime[0].y; + float fadeFrames = v_lifetime[0].z; + + if (animationFrame >= deathFrame) + return; + + float age = max(animationFrame - createFrame, 0.0); + /* Per particle: the appearance default normally, or the whole remaining + * life once the target is lost, so the spray dissolves as it coasts. */ + float fade = clamp((deathFrame - animationFrame) / max(fadeFrames, 0.01), 0.0, 1.0); + particleFade = fade; + + /* One hash per particle drives every random-looking property. Seeded from + * velocity and spawn frame so it survives a re-aim unchanged. */ + float particleHash = dot(v_velocity[0], vec3(12.9898, 78.233, 37.719)) + createFrame * 0.6180339; + vec3 randomValues = vec3( + hash11(particleHash + 1.7), + hash11(particleHash + 3.3), + hash11(particleHash + 5.9) + ); + + float sizeMultiplier = 1.0 + sizeVariation * (hash11(particleHash + 7.1) * 2.0 - 1.0); + // shrinks all the way to nothing, so the end of a fade is never a pop + float size = drawRadius * sizeMultiplier * fade; + float alpha = baseAlpha * (1.0 + alphaVariation * (hash11(particleHash + 11.3) * 2.0 - 1.0)); + vec4 color = vec4(equalizeColor(v_color[0].rgb), max(alpha, 0.0)); + + float haloSize = size * glowScale; + + if (outsideFrustum(center, haloSize)) + return; + + float rotValue = mix(-rotationRange, rotationRange, hash11(particleHash + 13.7)); + float rotVelocity = mix(-rotationRatePerFrame, rotationRatePerFrame, hash11(particleHash + 17.9)); + float rotation = radians(rotValue + rotVelocity * age); + vec3 phase = randomValues * TAU; + mat3 rotationMatrix = rotXYZ(phase + vec3(rotation, rotation * 1.3, rotation * 0.7)); + + vec3 noiseSeed = randomValues * (360.0 * 137.0) + vec3(11.0, 47.0, 83.0); + float seed = randomValues.x * TAU; + + vec3 xAxis = rotationMatrix * vec3(size, 0.0, 0.0); + vec3 yAxis = rotationMatrix * vec3(0.0, size, 0.0); + vec3 zAxis = rotationMatrix * vec3(0.0, 0.0, size); + vec3 normalX = rotationMatrix[0]; + vec3 normalY = rotationMatrix[1]; + vec3 normalZ = rotationMatrix[2]; + + emitFace( xAxis-yAxis-zAxis, xAxis+yAxis-zAxis, xAxis-yAxis+zAxis, xAxis+yAxis+zAxis, normalX, center, color, noiseSeed, seed); + emitFace(-xAxis-yAxis-zAxis, -xAxis-yAxis+zAxis, -xAxis+yAxis-zAxis, -xAxis+yAxis+zAxis, -normalX, center, color, noiseSeed, seed); + emitFace(-xAxis+yAxis-zAxis, -xAxis+yAxis+zAxis, xAxis+yAxis-zAxis, xAxis+yAxis+zAxis, normalY, center, color, noiseSeed, seed); + emitFace(-xAxis-yAxis-zAxis, xAxis-yAxis-zAxis, -xAxis-yAxis+zAxis, xAxis-yAxis+zAxis, -normalY, center, color, noiseSeed, seed); + emitFace(-xAxis-yAxis+zAxis, xAxis-yAxis+zAxis, -xAxis+yAxis+zAxis, xAxis+yAxis+zAxis, normalZ, center, color, noiseSeed, seed); + emitFace(-xAxis-yAxis-zAxis, -xAxis+yAxis-zAxis, xAxis-yAxis-zAxis, xAxis+yAxis-zAxis, -normalZ, center, color, noiseSeed, seed); + + emitGlow(center, color, haloSize, seed); +} diff --git a/cont/base/springcontent/shaders/GLSL/NanoParticleNoGeomVertProg.glsl b/cont/base/springcontent/shaders/GLSL/NanoParticleNoGeomVertProg.glsl new file mode 100644 index 00000000000..21515713e84 --- /dev/null +++ b/cont/base/springcontent/shaders/GLSL/NanoParticleNoGeomVertProg.glsl @@ -0,0 +1,184 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +/* + * Instanced path, stage 1 of 2. Used where geometry shaders are unavailable or + * NanoParticlesNoGeometryShader is set. + * + * Runs the same maths as NanoParticleGeomProg.glsl, but per template-mesh + * vertex instead of per emitted vertex: the mesh supplies the cube corners and + * the halo quad, and this shader places them. The helpers below are a verbatim + * copy of the geometry shader's; keep the two in step. + */ + +#version 150 compatibility + +// static template mesh (per vertex) +in vec3 templatePosition; +in vec3 templateNormal; +in vec2 templateGlowUV; +in float templateIsGlow; + +// particle (per instance) +in vec3 particleStartPos; +in vec3 particleVelocity; +in vec4 particleFrames; // x = createFrame, y = deathFrame, z = baseFrame, w = fadeFrames +in vec4 particleColor; + +uniform float animationFrame; +uniform vec3 cameraRight; +uniform vec3 cameraUp; +uniform vec4 clipPlane; + +uniform float drawRadius; +uniform float sizeVariation; +uniform float baseAlpha; +uniform float alphaVariation; +uniform float glowScale; +uniform float colorEqualize; +uniform float colorTargetLuma; +uniform float rotationRange; +uniform float rotationRatePerFrame; +/// World-space frustum planes of the active camera, same convention as CCamera::Frustum. +uniform vec4 frustumPlanes[6]; + +out vec4 g_color; +out vec3 g_normal; +out vec3 g_worldPos; +out vec3 g_localPos; +out vec3 g_noiseSeed; +out vec2 g_glowUV; +out float g_isGlow; +out float g_seed; +/// 1 = full strength, 0 = gone. Applied to the whole fragment, so the halo fades with the shape. +out float g_fade; +out float gl_ClipDistance[1]; + +const float TAU = 6.2831853; +const vec3 LUMA_WEIGHTS = vec3(0.2126, 0.7152, 0.0722); + +bool outsideFrustum(vec3 center, float radius) +{ + for (int i = 0; i < 6; ++i) { + if (dot(frustumPlanes[i].xyz, center) + frustumPlanes[i].w < -radius) + return true; + } + + return false; +} + +/* There is no geometry stage to return from here, so a vertex that should not + * exist is pushed outside clip space and degenerates instead. */ +void discardVertex() +{ + g_color = vec4(0.0); + g_normal = vec3(0.0); + g_worldPos = vec3(0.0); + g_localPos = vec3(0.0); + g_noiseSeed = vec3(0.0); + g_glowUV = vec2(0.0); + g_isGlow = 0.0; + g_seed = 0.0; + g_fade = 0.0; + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + gl_ClipDistance[0] = 1.0; +} + +float hash11(float value) +{ + return fract(sin(value) * 43758.5453); +} + +vec3 equalizeColor(vec3 color) +{ + float luma = dot(color, LUMA_WEIGHTS); + if (luma < 0.001) + return color; + + color *= pow(colorTargetLuma / luma, colorEqualize); + + float maxChannel = max(color.r, max(color.g, color.b)); + if (maxChannel > 1.0) + color /= maxChannel; + + return color; +} + +mat3 rotXYZ(vec3 angle) +{ + float cx = cos(angle.x), sx = sin(angle.x); + float cy = cos(angle.y), sy = sin(angle.y); + float cz = cos(angle.z), sz = sin(angle.z); + mat3 rotateX = mat3(1, 0, 0, 0, cx, sx, 0, -sx, cx); + mat3 rotateY = mat3(cy, 0, -sy, 0, 1, 0, sy, 0, cy); + mat3 rotateZ = mat3(cz, sz, 0, -sz, cz, 0, 0, 0, 1); + return rotateZ * rotateY * rotateX; +} + +void main() +{ + float createFrame = particleFrames.x; + float deathFrame = particleFrames.y; + float baseFrame = particleFrames.z; + float fadeFrames = particleFrames.w; + + // the buffer is rebuilt on a cadence, so a particle can outlive its last upload + if (animationFrame >= deathFrame) { + discardVertex(); + return; + } + + float age = max(animationFrame - createFrame, 0.0); + float motionAge = max(animationFrame - baseFrame, 0.0); + float fade = clamp((deathFrame - animationFrame) / max(fadeFrames, 0.01), 0.0, 1.0); + vec3 center = particleStartPos + particleVelocity * motionAge; + + float particleHash = dot(particleVelocity, vec3(12.9898, 78.233, 37.719)) + createFrame * 0.6180339; + vec3 randomValues = vec3( + hash11(particleHash + 1.7), + hash11(particleHash + 3.3), + hash11(particleHash + 5.9) + ); + + float sizeMultiplier = 1.0 + sizeVariation * (hash11(particleHash + 7.1) * 2.0 - 1.0); + // shrinks all the way to nothing, so the end of a fade is never a pop + float size = drawRadius * sizeMultiplier * fade; + float alpha = baseAlpha * (1.0 + alphaVariation * (hash11(particleHash + 11.3) * 2.0 - 1.0)); + + float haloSize = size * glowScale; + + if (outsideFrustum(center, haloSize)) { + discardVertex(); + return; + } + + g_color = vec4(equalizeColor(particleColor.rgb), max(alpha, 0.0)); + g_seed = randomValues.x * TAU; + g_fade = fade; + + vec3 worldOffset; + if (templateIsGlow > 0.5) { + worldOffset = (cameraRight * templateGlowUV.x + cameraUp * templateGlowUV.y) * haloSize; + g_normal = vec3(0.0, 1.0, 0.0); + g_localPos = vec3(0.0); + g_noiseSeed = vec3(0.0); + g_glowUV = templateGlowUV; + g_isGlow = 1.0; + } else { + float rotValue = mix(-rotationRange, rotationRange, hash11(particleHash + 13.7)); + float rotVelocity = mix(-rotationRatePerFrame, rotationRatePerFrame, hash11(particleHash + 17.9)); + float rotation = radians(rotValue + rotVelocity * age); + vec3 phase = randomValues * TAU; + mat3 rotationMatrix = rotXYZ(phase + vec3(rotation, rotation * 1.3, rotation * 0.7)); + + worldOffset = rotationMatrix * (templatePosition * size); + g_normal = rotationMatrix * templateNormal; + g_localPos = worldOffset; + g_noiseSeed = randomValues * (360.0 * 137.0) + vec3(11.0, 47.0, 83.0); + g_glowUV = vec2(0.0); + g_isGlow = 0.0; + } + + g_worldPos = center + worldOffset; + gl_Position = gl_ModelViewProjectionMatrix * vec4(g_worldPos, 1.0); + gl_ClipDistance[0] = dot(vec4(g_worldPos, 1.0), clipPlane); +} diff --git a/cont/base/springcontent/shaders/GLSL/NanoParticleVertProg.glsl b/cont/base/springcontent/shaders/GLSL/NanoParticleVertProg.glsl new file mode 100644 index 00000000000..99f5fec4d05 --- /dev/null +++ b/cont/base/springcontent/shaders/GLSL/NanoParticleVertProg.glsl @@ -0,0 +1,34 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +/* + * Geometry-shader path, stage 1 of 3. One vertex per particle; the geometry + * shader expands it into the shape and its halo. + * + * Position is reconstructed rather than uploaded per frame: the CPU only + * rewrites a particle when it is re-aimed, and then moves `baseFrame` forward + * with it. `createFrame` stays put so the per-particle hash - and therefore the + * particle's size, alpha and tumble - does not jump when that happens. + */ + +#version 150 compatibility + +in vec3 particleStartPos; +in vec3 particleVelocity; +in vec4 particleFrames; // x = createFrame, y = deathFrame, z = baseFrame, w = fadeFrames +in vec4 particleColor; + +uniform float animationFrame; + +out vec3 v_velocity; +out vec3 v_lifetime; // x = createFrame, y = deathFrame, z = fadeFrames +out vec4 v_color; + +void main() +{ + float motionAge = max(animationFrame - particleFrames.z, 0.0); + + gl_Position = vec4(particleStartPos + particleVelocity * motionAge, 1.0); + v_velocity = particleVelocity; + v_lifetime = particleFrames.xyw; + v_color = particleColor; +} diff --git a/doc/pr-changelogs/3191.md b/doc/pr-changelogs/3191.md new file mode 100644 index 00000000000..fdec975db5e --- /dev/null +++ b/doc/pr-changelogs/3191.md @@ -0,0 +1,48 @@ +### Nano particles +Added a standalone nano particle effect, off by default. When enabled it replaces legacy nano +spray at the point of emission; legacy nano projectiles are otherwise untouched, and the effect +falls back to them if no shader path is usable. + +* added `NanoParticlesGL4` boolean springsetting, default false. Renders nano particles as + shader-generated 3D shapes with an additive halo instead of textured billboards. +* added `NanoParticlesNoGeometryShader` boolean springsetting, default false. Forces the instanced + no-geometry-shader renderer. The effect already falls back to it automatically where geometry + shaders are unavailable. +* added `NanoParticlesRate` numerical springsetting, default 0.32, range 0-1. Emission multiplier. + Emission is proportional to the emitter's `buildSpeed * buildPower` rather than one particle per + work tick, so a builder's spray tracks the work it is actually doing rather than its nano piece + count. +* added `NanoParticlesHoming` boolean springsetting, default true. Particles follow moving unit + targets, and reclaim/capture particles follow the builder's nano piece. +* added `NanoParticlesGroundClamp` boolean springsetting, default true. Routes particles above + intervening terrain instead of letting them sink through it. +* added `NanoParticlesReclaimBurst` boolean springsetting, default false. Emits a one-shot burst + when reclaiming a unit finishes, sized by the unit's metal cost and split across the builders + that contributed. +* added `NanoParticlesTargetLostFade` boolean springsetting, default true. When the unit a spray + was aimed at is destroyed, cancelled mid-build, or crashing - or, for build and repair spray, + finished and at full health - the particles already in flight keep their course (still homing if + the unit is merely finished) and dissolve, shrinking and fading to nothing over a per-particle + window of roughly 30 frames, instead of flying on into nothing. Reclaim spray does the same when + its builder dies. +* added `NanoParticlesUpdateLuaUI` boolean springsetting, default false. Sends batched particle + lifecycle events to LuaUI, for deferred-lighting widgets. +* added `NanoParticlesUpdateLuaUISampleRate` numerical springsetting, default 0.25, range 0-1. + Fraction multiplier for how many particles are reported to LuaUI. +* the effect's remaining tunables - shape size, alpha, glow, colour equalisation, tumble, noise, + emission and burst curves, cache and culling parameters - are compiled-in defaults collected in + `rts/Rendering/Env/NanoParticles/NanoParticleConfig.h`, named and documented in one place rather + than spread through the sources as literals. Of note: `NanoParticlesRate` now scales close to + linearly, because the effect holds its spawn gate open until most of `MaxNanoParticles` is spent + instead of throttling in proportion to the budget already used from the first particle onward. +* nano particles from the effect appear on the minimap, as the legacy ones do. + +### Lua wupget API +* added `wupget:NanoParticleUpdate(events, eventCount, gameFrame)` unsynced callin. Batched nano + particle lifecycle events, as one flat numeric array of 13-entry records + `{operation, lightID, px, py, pz, vx, vy, vz, remainingLife, r, g, b, builderBuildSpeed}`. + Operations are 1 = spawn, 2 = update, 3 = remove, 4 = reset. Only fires while both + `NanoParticlesGL4` and `NanoParticlesUpdateLuaUI` are on. +* added `Engine.FeatureSupport.nanoParticleUpdateCallin` boolean, to detect the above. +* added `Engine.FeatureSupport.nanoParticlesGL4` boolean, so games can detect that the engine has + the standalone nano particle effect and retire their own Lua implementation of it. diff --git a/doc/site/content/changelogs/_index.markdown b/doc/site/content/changelogs/_index.markdown index d302589cf06..ef8ee0fd676 100644 --- a/doc/site/content/changelogs/_index.markdown +++ b/doc/site/content/changelogs/_index.markdown @@ -7,4 +7,4 @@ title = "Running changelog" This is the bleeding-edge changelog since version 2026.07, for **pre-release 2026.08**. -No changes as of yet. +No changes as of yet. \ No newline at end of file diff --git a/rts/Game/Game.cpp b/rts/Game/Game.cpp index d58c51301a0..32f267d1756 100644 --- a/rts/Game/Game.cpp +++ b/rts/Game/Game.cpp @@ -40,6 +40,7 @@ #include "Rendering/Fonts/glFont.h" #include "Rendering/CommandDrawer.h" #include "Rendering/LineDrawer.h" +#include "Rendering/Env/NanoParticles/NanoParticleSystem.h" #include "Rendering/GlobalRendering.h" #include "Rendering/DebugDrawerAI.h" #include "Rendering/HUDDrawer.h" @@ -686,6 +687,7 @@ void CGame::PostLoadSimulation(LuaParser* defsParser) unitHandler.Init(); featureHandler.Init(); projectileHandler.Init(); + NanoParticles::Init(); CLosHandler::InitStatic(); readMap->InitHeightMapDigestVectors(losHandler->los.size); @@ -1059,6 +1061,7 @@ void CGame::KillSimulation() featureHandler.Kill(); // depends on unitHandler (via ~CFeature) unitHandler.Kill(); projectileHandler.Kill(); + NanoParticles::Kill(); LOG("[Game::%s][3]", __func__); IPathManager::FreeInstance(pathManager); @@ -1776,6 +1779,7 @@ void CGame::SimFrame() { unitHandler.Update(); pathManager->Update(); projectileHandler.Update(); + NanoParticles::system.Update(); featureHandler.Update(); { /* The default GAME_SPEED is 30, which doesn't divide 1000 well, diff --git a/rts/Game/UI/MiniMap.cpp b/rts/Game/UI/MiniMap.cpp index 0f4c893e702..aa8a4b27168 100644 --- a/rts/Game/UI/MiniMap.cpp +++ b/rts/Game/UI/MiniMap.cpp @@ -30,6 +30,7 @@ #include "Rendering/ShadowHandler.h" #include "Rendering/DebugVisibilityDrawer.h" #include "Rendering/Map/InfoTexture/IInfoTextureHandler.h" +#include "Rendering/Env/NanoParticles/NanoParticleRenderer.h" #include "Rendering/Env/Particles/ProjectileDrawer.h" #include "Rendering/Units/UnitDrawer.h" #include "Rendering/GL/myGL.h" @@ -2000,6 +2001,8 @@ void CMiniMap::DrawWorldStuff() const // draw the projectiles if (drawProjectiles) { + // shares the projectile minimap buffer, so it has to fill before the submit + NanoParticles::DrawOnMinimap(); projectileDrawer->DrawProjectilesMiniMap(); } diff --git a/rts/Lua/LuaConstEngine.cpp b/rts/Lua/LuaConstEngine.cpp index 50d042462ed..2469286bf9d 100644 --- a/rts/Lua/LuaConstEngine.cpp +++ b/rts/Lua/LuaConstEngine.cpp @@ -29,6 +29,8 @@ * @field groupAddDoesntSelect boolean Whether 'group add' also selects the group (does both if false) * @field deadTeamsKeepUnitLimit boolean Whether engine redistributes dead team unitlimit to allies (false) or keeps it as-is (true) * @field reliableLuaMapShaders boolean Whether forward-only Lua map shaders activate without a deferred draw and Spring.SetMapShader program swaps refresh cached uniform locations + * @field nanoParticleUpdateCallin boolean Whether LuaUI receives batched `NanoParticleUpdate` lifecycle events + * @field nanoParticlesGL4 boolean Whether the engine has the standalone shader-based nano particle effect (the `NanoParticles*` springsettings) */ /*** @@ -70,7 +72,7 @@ bool LuaConstEngine::PushEntries(lua_State* L) * * will be compatible even on engines that don't yet know about the entry at all. */ lua_pushliteral(L, "FeatureSupport"); - lua_createtable(L, 0, 11); + lua_createtable(L, 0, 16); LuaPushNamedBool(L, "NegativeGetUnitCurrentCommand", true); LuaPushNamedBool(L, "hasExitOnlyYardmaps", true); LuaPushNamedNumber(L, "rmlUiApiVersion", 1); @@ -85,6 +87,8 @@ bool LuaConstEngine::PushEntries(lua_State* L) LuaPushNamedBool(L, "groupAddDoesntSelect", true); LuaPushNamedBool(L, "deadTeamsKeepUnitLimit", false); LuaPushNamedBool(L, "reliableLuaMapShaders", true); + LuaPushNamedBool(L, "nanoParticleUpdateCallin", true); + LuaPushNamedBool(L, "nanoParticlesGL4", true); lua_rawset(L, -3); lua_pushliteral(L, "textColorCodes"); diff --git a/rts/Lua/LuaHandle.cpp b/rts/Lua/LuaHandle.cpp index c0baa78cf78..ea2ee64ec82 100644 --- a/rts/Lua/LuaHandle.cpp +++ b/rts/Lua/LuaHandle.cpp @@ -32,6 +32,7 @@ #include "Sim/Misc/GlobalSynced.h" #include "Sim/Misc/TeamHandler.h" #include "Sim/Projectiles/ExplosionGenerator.h" +#include "Rendering/Env/NanoParticles/NanoParticleDefs.h" #include "Sim/Projectiles/Projectile.h" #include "Sim/Projectiles/WeaponProjectiles/WeaponProjectile.h" #include "Sim/Features/FeatureDef.h" @@ -2645,6 +2646,69 @@ void CLuaHandle::SunChanged() RunCallIn(L, cmdStr, 0, 0); } +/*** Batched nano particle lifecycle changes. + * + * Only sent while the `NanoParticlesGL4` and `NanoParticlesUpdateLuaUI` + * springsettings are both on, and only for the sampled fraction of particles + * set by `NanoParticlesUpdateLuaUISampleRate` - one event per particle per + * frame would overwhelm any consumer. Intended for deferred-lighting widgets. + * + * Events are passed as one flat numeric array to keep the marshalling cost + * down. Each event occupies 13 consecutive entries: + * `{operation, lightID, px, py, pz, vx, vy, vz, remainingLife, r, g, b, builderBuildSpeed}`. + * + * Operations are 1 = spawn, 2 = update, 3 = remove, 4 = reset. + * + * A particle's lifetime is fixed when it spawns, so `remainingLife` is exact and + * consumers are expected to expire their own state from it; operation 3 is + * reserved and currently never sent. A reset means every previously reported + * lightID is gone and any state keyed on them should be dropped; it is always + * the first event of its batch. + * + * Particles are not projectiles and have no projectile ID; `lightID` is unique, + * negative, and only valid until the particle expires or a reset arrives. + * + * @function Callins:NanoParticleUpdate + * @param events number[] Flat event records. + * @param eventCount integer Number of records in `events`. + * @param gameFrame integer Current simulation frame. + */ +void CLuaHandle::NanoParticleUpdate(const std::vector& events) +{ + ZoneScopedN("NanoParticles::LuaUpdate:CallIn"); + LUA_CALL_IN_CHECK(L); + luaL_checkstack(L, 5, __func__); + static const LuaHashString cmdStr(__func__); + if (!cmdStr.GetGlobalFunc(L)) + return; + + constexpr int EVENT_STRIDE = 13; + + lua_createtable(L, static_cast(events.size()) * EVENT_STRIDE, 0); + + int tableIndex = 1; + for (const NanoParticles::Event& event: events) { + lua_pushinteger(L, static_cast(event.type)); lua_rawseti(L, -2, tableIndex++); + lua_pushinteger(L, event.lightID); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.pos.x); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.pos.y); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.pos.z); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.velocity.x); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.velocity.y); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.velocity.z); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.remainingLife); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.color.x); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.color.y); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.color.z); lua_rawseti(L, -2, tableIndex++); + lua_pushnumber (L, event.builderBuildSpeed); lua_rawseti(L, -2, tableIndex++); + } + + lua_pushinteger(L, static_cast(events.size())); + lua_pushinteger(L, gs->frameNum); + + RunCallIn(L, cmdStr, 3, 0); +} + /*** Used to set the default command when a unit is selected. * * @function Callins:DefaultCommand diff --git a/rts/Lua/LuaHandle.h b/rts/Lua/LuaHandle.h index 40d7470daa2..caaa5258dfb 100644 --- a/rts/Lua/LuaHandle.h +++ b/rts/Lua/LuaHandle.h @@ -200,6 +200,7 @@ class CLuaHandle : public CEventClient void UnsyncedHeightMapUpdate(const SRectangle& rect) override; void Update() override; + void NanoParticleUpdate(const std::vector& events) override; void KeyBindingsChanged() override; bool KeyMapChanged() override; diff --git a/rts/Rendering/CMakeLists.txt b/rts/Rendering/CMakeLists.txt index 34e627297c7..8c1557f1982 100644 --- a/rts/Rendering/CMakeLists.txt +++ b/rts/Rendering/CMakeLists.txt @@ -30,6 +30,10 @@ set(sources_engine_Rendering "${CMAKE_CURRENT_SOURCE_DIR}/Env/WaterRendering.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Env/Decals/GroundDecal.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Env/Decals/GroundDecalHandler.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Env/NanoParticles/NanoParticleConfig.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Env/NanoParticles/NanoParticleEmitter.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Env/NanoParticles/NanoParticleRenderer.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Env/NanoParticles/NanoParticleSystem.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Env/Particles/ProjectileDrawer.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Env/Particles/Classes/BitmapMuzzleFlame.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Env/Particles/Classes/BubbleProjectile.cpp" diff --git a/rts/Rendering/Env/IWater.cpp b/rts/Rendering/Env/IWater.cpp index 8b9ee21f3df..8c260ebf7b2 100644 --- a/rts/Rendering/Env/IWater.cpp +++ b/rts/Rendering/Env/IWater.cpp @@ -13,6 +13,7 @@ #include "Map/BaseGroundDrawer.h" #include "Rendering/Features/FeatureDrawer.h" #include "Rendering/Units/UnitDrawer.h" +#include "Rendering/Env/NanoParticles/NanoParticleRenderer.h" #include "Rendering/Env/Particles/ProjectileDrawer.h" #include "Sim/Projectiles/ExplosionListener.h" #include "System/Config/ConfigHandler.h" @@ -167,6 +168,7 @@ void IWater::DrawReflections(const double* clipPlaneEqs, bool drawGround, bool d unitDrawer->DrawAlphaPass(true); featureDrawer->DrawAlphaPass(true); projectileDrawer->DrawAlpha(true, false, true, false); + NanoParticles::Draw(true, false, true, false); // sun-disc does not blend well with water eventHandler.DrawWorldReflection(); @@ -209,6 +211,7 @@ void IWater::DrawRefractions(const double* clipPlaneEqs, bool drawGround, bool d unitDrawer->DrawAlphaPass(false, true); featureDrawer->DrawAlphaPass(false, true); projectileDrawer->DrawAlpha(false, true, false, true); + NanoParticles::Draw(false, true, false, true); eventHandler.DrawWorldRefraction(); glDisable(GL_CLIP_PLANE2); diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleConfig.cpp b/rts/Rendering/Env/NanoParticles/NanoParticleConfig.cpp new file mode 100644 index 00000000000..c860b2b3606 --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleConfig.cpp @@ -0,0 +1,115 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#include "NanoParticleConfig.h" + +#include + +#include "System/Config/ConfigHandler.h" + +/* + * The player-facing switches. Everything else is a modrule; see + * NanoParticleConfig.h for why the split is drawn here. + */ +CONFIG(bool, NanoParticlesGL4) + .defaultValue(false) + .safemodeValue(false) + .headlessValue(false) + .description("Render nano particles as shader-generated 3D shapes instead of textured billboards"); +CONFIG(bool, NanoParticlesNoGeometryShader) + .defaultValue(false) + .safemodeValue(false) + .headlessValue(false) + .description("Force the instanced no-geometry-shader nano particle renderer"); +CONFIG(bool, NanoParticlesHoming) + .defaultValue(true) + .safemodeValue(false) + .headlessValue(false) + .description("Allow nano particles to follow moving unit targets and builder nano pieces"); +CONFIG(bool, NanoParticlesGroundClamp) + .defaultValue(true) + .safemodeValue(false) + .headlessValue(false) + .description("Route nano particles above intervening terrain"); +CONFIG(bool, NanoParticlesReclaimBurst) + .defaultValue(false) + .safemodeValue(false) + .headlessValue(false) + .description("Emit a nano burst when reclaiming a unit finishes"); +CONFIG(float, NanoParticlesRate) + .defaultValue(0.32f) + .minimumValue(0.0f) + .maximumValue(1.0f) + .description("Per-emitter nano emission multiplier; also scales the minimum visual feedback cadence"); +CONFIG(bool, NanoParticlesTargetLostFade) + .defaultValue(true) + .description("Nano particles fade out and shrink when the unit they were aimed at is destroyed, cancelled, or finished, instead of flying on into nothing"); +CONFIG(bool, NanoParticlesUpdateLuaUI) + .defaultValue(false) + .safemodeValue(false) + .headlessValue(false) + .description("Send batched nano particle lifecycle updates to LuaUI"); +CONFIG(float, NanoParticlesUpdateLuaUISampleRate) + .defaultValue(0.25f) + .minimumValue(0.0f) + .maximumValue(1.0f) + .description("Fraction multiplier for nano particles reported to LuaUI (used for deferred lights)"); + +namespace NanoParticles { + +namespace { + Config config; + + const std::vector observedConfigKeys = { + "NanoParticlesGL4", + "NanoParticlesNoGeometryShader", + "NanoParticlesHoming", + "NanoParticlesGroundClamp", + "NanoParticlesReclaimBurst", + "NanoParticlesRate", + "NanoParticlesTargetLostFade", + "NanoParticlesUpdateLuaUI", + "NanoParticlesUpdateLuaUISampleRate", + }; + +} // namespace + +const Config& GetConfig() { return config; } + +const std::vector& GetObservedConfigKeys() { return observedConfigKeys; } + +void InitConfig() +{ + config.enabled = configHandler->GetBool("NanoParticlesGL4"); + config.forceNoGeometryShader = configHandler->GetBool("NanoParticlesNoGeometryShader"); + config.homing = configHandler->GetBool("NanoParticlesHoming"); + config.groundClamp = configHandler->GetBool("NanoParticlesGroundClamp"); + config.reclaimBurst = configHandler->GetBool("NanoParticlesReclaimBurst"); + config.luaUpdates = configHandler->GetBool("NanoParticlesUpdateLuaUI"); + config.rate = std::clamp(configHandler->GetFloat("NanoParticlesRate"), 0.0f, 1.0f); + config.targetLostFade = configHandler->GetBool("NanoParticlesTargetLostFade"); + config.luaUpdate.sampleRate = std::clamp(configHandler->GetFloat("NanoParticlesUpdateLuaUISampleRate"), 0.0f, 1.0f); + + ++config.generation; +} + +bool ReloadConfigSetting(const std::string& key) +{ + const Config previous = config; + + if (std::find(observedConfigKeys.begin(), observedConfigKeys.end(), key) == observedConfigKeys.end()) + return false; + + InitConfig(); + + return previous.enabled != config.enabled + || previous.forceNoGeometryShader != config.forceNoGeometryShader + || previous.homing != config.homing + || previous.groundClamp != config.groundClamp + || previous.reclaimBurst != config.reclaimBurst + || previous.luaUpdates != config.luaUpdates + || previous.rate != config.rate + || previous.targetLostFade != config.targetLostFade + || previous.luaUpdate.sampleRate != config.luaUpdate.sampleRate; +} + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleConfig.h b/rts/Rendering/Env/NanoParticles/NanoParticleConfig.h new file mode 100644 index 00000000000..c1a6329ca84 --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleConfig.h @@ -0,0 +1,289 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace NanoParticles { + +/* + * Every tunable of the standalone nano particle effect lives here, in one + * struct, so the look and feel can be iterated on without hunting through the + * renderer/system sources for bare literals. + * + * The handful of knobs a player is expected to touch are springsettings, listed + * in NanoParticleConfig.cpp and live-reloadable through ConfigNotify. Everything + * else is a compiled-in default: tuned here, in one place, rather than exposed + * as content configuration. + */ + +/// Knobs that decide how many particles are emitted and how fast they travel. +struct EmissionConfig { + /// Elmos per frame a nano particle covers. Legacy nano projectiles use 3. + float particleSpeed = 4.0f; + /// Per-particle speed spread, +/- this fraction. Also shortens/extends lifetime to match. + float speedVariation = 0.14f; + /// Emission is proportional to (buildSpeed * buildPower) / this reference buildSpeed. + float referenceBuildSpeed = 100.0f; + /* + * Scales the emission spread the caller asked for. 1.0 reproduces legacy + * nano spray exactly; the 3D shapes read better as discrete chunks with a + * somewhat tighter stream, so games may want to pull this down. + */ + float directionJitterScale = 1.0f; + /* + * If a builder has build power but its proportional rate is too low to have + * produced a particle for this many frames, force one out so the player still + * gets feedback. The forced emit is debited from the accumulator, so the + * long-run rate stays proportional. + */ + int feedbackEmitMinGap = 60; + /// The gap above is stated at this rate; it is rescaled when NanoParticlesRate differs. + float feedbackEmitReferenceRate = 0.32f; + /// Emitter bookkeeping for a unit is dropped after this many idle frames. + int emitterStateMaxIdleFrames = 300; + /* + * Fraction of the particle budget that may be spent before spawns start + * being rejected. + * + * Legacy nano spray throttles in proportion to how much of the budget is + * already used, starting from the very first particle. That makes the live + * count settle at T*E*L / (T + E*L) for a request rate E and lifetime L, so + * it approaches the budget T hyperbolically and NanoParticlesRate stops + * doing much well before the budget is full. Holding the gate fully open up + * to this fraction keeps the response linear across the useful range, and + * only ramps rejection in over the last stretch so a full budget is still + * shared between emitters rather than taken by whoever asks first. + */ + float budgetSoftStart = 0.85f; +}; + +/// One-shot burst fired when a unit finishes being reclaimed. +struct ReclaimBurstConfig { + /// Particles each contributing builder emits regardless of the reclaimee's cost. + int base = 1; + /// How quickly each builder's share grows with the reclaimee's metal cost. + float logK = 40.0f; + /// Metal cost at or below which a builder emits roughly `base` particles. + float logNorm = 250.0f; + /// Sub-linear exponent on contributor count: total = perBuilder * count^exponent. + float builderExponent = 0.5f; + /// Hard ceiling on the total particle count across all contributors. + int maxParticles = 1500; + /// Burst spawns inside this fraction of the reclaimee's collision volume. + float volumeFraction = 0.55f; + /// Direction jitter for burst particles, as a fraction of their travel length. + float directionJitter = 0.10f; + /// A builder counts as a contributor if it poured reclaim within this many frames. + int contributorMaxAge = 30; +}; + +/// Particles curving toward a moving target (unit midpos, or a builder nano piece). +struct HomingConfig { + /// Re-aim every Nth frame. Particles move little between frames, so this is invisible. + int runEveryFrames = 4; + /// Slots in the per-frame target-position cache. Power of two. + std::uint32_t targetCacheSlots = 256; +}; + +/// Routing particles over terrain that would otherwise swallow them. +struct GroundClampConfig { + /// Particles are held this far above the ground height. + float margin = 11.0f; + /// Only clamp when the path dips more than this far below the margin. + float smartDelta = 4.0f; + /// Re-evaluate the route this many frames after a clamp was needed. + int recheckFramesHit = 6; + /// Re-evaluate the route this many frames after a clamp was not needed. + int recheckFramesMiss = 12; + /// Quantisation of the ground-height cache, in elmos. Power of two. + float heightCacheCellSize = 16.0f; + /// Slots in the ground-height cache. Power of two. + std::uint32_t heightCacheSlots = 1024; + /// Quantisation of the route cache endpoints, in elmos. + float routeCacheCellSize = 64.0f; + /// Route-cache entries stay valid for this many frames. + int routeCacheFrames = 45; + /// Slots in the route cache. Power of two. + std::uint32_t routeCacheSlots = 1024; + /// Horizontal length squared above which the denser sample set is used. + float longPathThresholdSq = 4096.0f; + /// Path fractions sampled for short hops. + std::array shortSamples = {0.35f, 0.50f, 0.65f}; + /// Path fractions sampled for long hops. + std::array longSamples = {0.12f, 0.22f, 0.35f, 0.50f, 0.65f, 0.78f, 0.90f, 0.96f}; +}; + +/* + * Fading a particle out when the unit it was bound to is lost - destroyed, + * cancelled mid-build, crashing - or, for build and repair spray, when the work + * is done. Rather than vanishing or flying on into nothing, the trailing spray + * keeps its course and dissolves: alpha and size ramp down over a per-particle + * window, and the particle dies when the window closes. + */ +struct TargetLostFadeConfig { + /// Base fade duration, in frames. + float durationFrames = 30.0f; + /// Check each particle's target every Nth frame, staggered per particle. + int checkEveryFrames = 4; + /* + * Per-particle fade duration is durationFrames scaled by a random factor in + * [jitterMin, jitterMax), so a stream dissolves unevenly rather than + * winking out on one frame. + */ + float jitterMin = 0.75f; + float jitterMax = 1.25f; + /// Slots in the per-frame target-state cache. Power of two. + std::uint32_t cacheSlots = 256; +}; + +/// Line-of-sight filtering of particles belonging to other allyteams. +struct VisibilityConfig { + /// An LOS answer is reused for this many frames. + int losCacheFrames = 7; + /// Quantisation of the LOS cache, in elmos. + float losCacheCellSize = 64.0f; + /// Slots in the LOS cache. Power of two. + std::uint32_t losCacheSlots = 1024; +}; + +/// Buffer management and culling on the render side. +struct RenderConfig { + /// Rebuild the persistent (own/allied) vertex buffer at most every Nth frame. + int bufferSyncIntervalFrames = 4; + /// Enemy particles are binned into cells of this size for frustum rejection. + float enemyCellSize = 128.0f; + /// Conservative per-particle radius used for frustum tests, in elmos. + float cullRadius = 22.0f; + /* + * Minimap streak length, expressed as frames of travel. One frame is what + * legacy nano projectiles draw, but at ~4 elmos that is well under a pixel + * on a normal-sized minimap of a large map, so the spray is only visible + * magnified. 0 draws a point per particle instead, which is always at least + * one pixel. + */ + float minimapStreakFrames = 10.0f; + /// Draw a point at the head of each streak so particles stay visible when zoomed out. + bool minimapPoints = true; + /* + * Whether to draw in the water reflection and refraction passes. + * + * Off by default: those two passes double the number of times the effect is + * drawn per frame, and each one pays a full set of state changes and uniform + * uploads before a single particle is submitted. Nano spray is small and + * bright, so what it contributes to a reflection is marginal next to that. + */ + bool drawInWaterPasses = false; +}; + +/* + * Purely visual knobs. These are uploaded to the shaders as uniforms rather + * than baked in as GLSL constants, so both the geometry and the no-geometry + * path read the same numbers and a tweak needs no shader edit. + */ +struct AppearanceConfig { + /// Half-extent of the particle shape, in elmos. The cube spans ~2x this. + float drawRadius = 1.5f; + /// Per-particle size spread, +/- this fraction. + float sizeVariation = 0.3f; + /// Base alpha of a particle, 0-1. + float baseAlpha = 50.0f / 255.0f; + /// Per-particle alpha spread, +/- this fraction. + float alphaVariation = 2.5f; + /// End-of-life alpha/size ramp, in frames. + float fadeFrames = 4.0f; + /// Additive halo size, as a multiple of the shape size. + float glowScale = 11.0f; + /// Additive halo brightness. + float glowIntensity = 0.35f; + /// Additive halo radial falloff exponent. Higher is tighter. + float glowFalloff = 9.5f; + /// Team-color brightness equalisation strength, 0 = raw team color, 1 = full. + float colorEqualize = 0.7f; + /// Luma the equalisation aims for. + float colorTargetLuma = 0.55f; + /// Per-particle hue wobble. + float hueJitter = 0.1f; + /// Face shading multiplier. Kept modest so dark faces still read as solid. + float coreBoost = 0.3f; + /// View-dependent face shading: 0 = flat, higher = back faces visible but dimmed. + float showInside = 4.0f; + /// Internal noise amplitude. + float noiseAmount = 6.0f; + /// Internal noise scroll speed, in units per second. + float noiseSpeed = 25.0f; + /// Internal noise spatial frequency. + float noiseScale = 1.75f; + /// Strength of the white hot spots the noise punches through. + float whiteHotspot = 1.5f; + /// Noise value above which a hot spot starts to appear. + float whiteHotspotThreshold = 0.6f; + /// Base tumble angle range, in degrees: [-rotationRange, +rotationRange]. + float rotationRange = 180.0f; + /// Tumble rate range, in degrees per second: [-rotationRate, +rotationRate]. + float rotationRate = 40.0f; +}; + +/// Sampling of the batched NanoParticleUpdate callin LuaUI receives. +struct LuaUpdateConfig { + /// Multiplier folded into the per-particle selection fraction. + float sampleRate = 0.25f; + /// Multiplier of the integer-hash used to pick which particles are reported. + double hashMultiplier = 2654435761.0; + /// Range the hash is folded into before comparing against the sample fraction. + double hashRange = 1000000.0; + /// Initial capacity of each per-thread event queue. + std::size_t threadQueueReserve = 64; +}; + +struct Config { + // --- springsettings (see NanoParticleConfig.cpp) --------------------- + /// Master switch. When off, nano spray falls through to legacy CNanoProjectile. + bool enabled = false; + /// Force the instanced no-geometry-shader path even where a geometry shader works. + bool forceNoGeometryShader = false; + /// Curve particles toward moving targets. + bool homing = true; + /// Route particles above intervening terrain. + bool groundClamp = true; + /// Fire a burst when reclaiming a unit finishes. + bool reclaimBurst = false; + /// Send batched lifecycle updates to LuaUI (used by deferred-lighting widgets). + bool luaUpdates = false; + /// Global emission multiplier, 0-1. + float rate = 0.32f; + /// Dissolve particles whose target is lost; see TargetLostFadeConfig. + bool targetLostFade = true; + + // --- compiled-in defaults, tuned here --------------------------------- + EmissionConfig emission; + ReclaimBurstConfig reclaimBurstParams; + HomingConfig homingParams; + GroundClampConfig groundClampParams; + TargetLostFadeConfig targetLostFadeParams; + VisibilityConfig visibility; + RenderConfig render; + AppearanceConfig appearance; + LuaUpdateConfig luaUpdate; + + /// Bumped whenever anything changes, so caches keyed on it can invalidate. + std::uint32_t generation = 1; +}; + +/// The single live instance. Read-only outside of this translation unit. +const Config& GetConfig(); + +/// Reads the springsettings. Called once during startup. +void InitConfig(); + +/// Re-reads the springsettings after a live change. Returns true if anything changed. +bool ReloadConfigSetting(const std::string& key); + +/// Names of the springsettings the effect wants ConfigNotify callbacks for. +const std::vector& GetObservedConfigKeys(); + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleDefs.h b/rts/Rendering/Env/NanoParticles/NanoParticleDefs.h new file mode 100644 index 00000000000..91f02f6cad6 --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleDefs.h @@ -0,0 +1,142 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#pragma once + +#include + +#include "System/Color.h" +#include "System/float3.h" + +class CUnit; + +namespace NanoParticles { + +/* + * A nano particle is a pure render-side POD: it never enters the projectile + * containers, has no id in the projectile handler, and is not visible to Lua as + * a projectile. Motion is analytic, so the shader reconstructs the position + * from `startPos + velocity * (frame - baseFrame)` and the CPU only touches a + * particle when it homes or has to clear terrain. + */ +struct Particle { + /// Position the particle occupied at `baseFrame`. + float3 startPos; + /// Elmos per frame. + float3 velocity; + SColor color; + + /* + * `baseFrame` moves forward whenever the particle is re-aimed, so the + * analytic position stays correct without replaying the whole path. + * `createFrame` never moves: it seeds the per-particle hash in the shader, + * so re-aiming does not make a particle visibly change size or tumble. + */ + int baseFrame = 0; + int createFrame = 0; + int deathFrame = 0; + /* + * Frame the spray would land on its target. Homing and ground clamp pace + * their re-aims against this rather than deathFrame: a target-lost fade + * pulls deathFrame earlier, and pacing against that would make every + * subsequent re-aim speed the particle up to arrive before it dies. + */ + int arriveFrame = 0; + + /// Unique and negative, so it cannot be confused with a projectile id. Also the LuaUI light id. + int id = -1; + int allyTeam = -1; + /// buildSpeed of the emitting builder; passed through to LuaUI for light sizing. + float builderBuildSpeed = 0.0f; + + /* + * Frames the shader ramps alpha and size down over before `deathFrame`. + * Starts at the appearance default; a target-lost fade shortens the + * particle's life and widens this so the whole remainder is the ramp. + */ + float fadeFrames = 0.0f; + + // --- target (the unit this particle is bound to) ----------------------- + /* + * The workpiece for a forward spray, the builder for an inverse one. Homing + * re-aims at it when enabled; the target-lost fade watches it either way. + */ + int targetID = -1; + std::int64_t targetSyncID = -1; + /// Nano piece on the target to follow, or -1 for its midpos. + int targetPiece = -1; + + // --- homing (only meaningful while `homing` is set) ------------------ + float homingSpeedLimitSq = 0.0f; + float3 homingOffset; + + // --- ground clamp (only meaningful while `groundClamp` is set) ------- + float3 groundClampFinalPos; + float3 groundClampWaypointPos; + int groundClampWaypointFrame = -1; + int groundClampNextFrame = -1; + + // --- LuaUI reporting ------------------------------------------------- + /// Sampling generation this particle's selection was decided under. + std::uint32_t luaSampleGeneration = 0; + /// Whether this particle was picked for the LuaUI update sample. + bool luaSelected = false; + /// Whether the Spawn event has already gone out for it. + bool luaSpawnReported = false; + + /// Staggers the per-particle homing/clamp work across frames. + std::uint8_t updatePhase = 0; + bool homing = false; + bool groundClamp = false; + /// Also fade when the target is finished and at full health, i.e. the work is done. + bool fadeWhenTargetComplete = false; + /// Set once the target-lost fade has shortened this particle's life. + bool fading = false; +}; + +/// Extra spawn context the emitter hands to the system; empty for plain sprays. +struct SpawnParams { + /* + * Unit the spray is bound to: the workpiece for a forward spray, the + * builder for an inverse one. Homing follows it when enabled; losing it + * fades the particle out. + */ + const CUnit* target = nullptr; + /// Nano piece on `target` to track, or -1 for its midpos. + int targetPiece = -1; + /// Reclaim-style particle: it travels from the target back to the builder. + bool inverse = false; + /* + * The spray represents work that ends when the target is finished and at + * full health - build and repair, but not capture, whose target is usually + * healthy from the start. Fades the particle out when that point is reached. + */ + bool fadeWhenTargetComplete = false; +}; + +/* + * Mirrors the operation ids documented on the NanoParticleUpdate callin. + * + * `Remove` is reserved and never emitted: a particle's death frame is fixed at + * spawn and reported as `remainingLife`, so a consumer can expire its own state + * without a second event per particle. `Reset` covers the cases where that is + * not enough - the sampling changed, or the effect was switched off. + */ +enum class EventType : std::uint8_t { + Spawn = 1, + Update = 2, + Remove = 3, + Reset = 4, +}; + +/// One entry of the batch handed to LuaUI each sim frame. +struct Event { + EventType type = EventType::Update; + int lightID = -1; + float3 pos; + float3 velocity; + float remainingLife = 0.0f; + float3 color; + float builderBuildSpeed = 0.0f; +}; + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleEmitter.cpp b/rts/Rendering/Env/NanoParticles/NanoParticleEmitter.cpp new file mode 100644 index 00000000000..77f8fd0bd92 --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleEmitter.cpp @@ -0,0 +1,335 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#include "NanoParticleEmitter.h" + +#include +#include + +#include "NanoParticleConfig.h" +#include "NanoParticleDefs.h" + +#include "Game/GlobalUnsynced.h" +#include "Sim/Misc/CollisionVolume.h" +#include "Sim/Misc/GlobalSynced.h" +#include "Sim/Projectiles/ProjectileHandler.h" +#include "Sim/Units/Unit.h" +#include "Sim/Units/UnitDef.h" +#include "Sim/Units/UnitHandler.h" +#include "Sim/Units/UnitTypes/Builder.h" +#include "Sim/Units/UnitTypes/Factory.h" +#include "System/SpringMath.h" + +#include "System/Misc/TracyDefs.h" + +namespace NanoParticles { + +Emitter emitter; + +namespace { + /// How often the idle-emitter sweep runs, in frames. + constexpr int PRUNE_INTERVAL_FRAMES = 150; + /// Burst particles closer to the builder than this are skipped; the direction is meaningless. + constexpr float BURST_MIN_LENGTH = 1.0f; + + /* + * Walks `emitCount` of `unit`'s nano pieces, starting from the one the script + * just handed us, and calls `emit(modelNanoPiece, worldPos)` for each piece + * that still exists on the model. + * + * Spreading a tick's emissions over the pieces is what keeps a multi-armed + * builder from firing its whole allowance out of a single arm; starting from + * the script's pick keeps the leading arm as random as it was before. + */ + template + void ForEachEmission(const CUnit* unit, const std::vector& nanoPieces, int firstNanoPiece, int emitCount, EmitFn&& emit) + { + const auto firstIt = std::find(nanoPieces.begin(), nanoPieces.end(), firstNanoPiece); + const std::size_t firstIndex = (firstIt != nanoPieces.end()) ? std::distance(nanoPieces.begin(), firstIt) : 0; + + for (int i = 0; i < emitCount; ++i) { + const int modelNanoPiece = nanoPieces.empty() + ? firstNanoPiece + : nanoPieces[(firstIndex + i) % nanoPieces.size()]; + + if (!unit->localModel.HasPiece(modelNanoPiece)) + continue; + + emit(modelNanoPiece, unit->GetObjectSpacePos(unit->localModel.GetRawPiecePos(modelNanoPiece))); + } + } +} // namespace + + +void Emitter::Init() +{ + emitterStates.clear(); + reclaimTrackers.clear(); + nextPruneFrame = 0; +} + +void Emitter::Kill() +{ + emitterStates.clear(); + reclaimTrackers.clear(); + nextPruneFrame = 0; +} + + +int Emitter::TakeEmitCount(int unitID, float builderBuildSpeed, float buildPower) +{ + RECOIL_DETAILED_TRACY_ZONE; + const Config& cfg = GetConfig(); + const EmissionConfig& ec = cfg.emission; + const int frame = gs->frameNum; + + EmitterState& state = emitterStates[unitID]; + state.lastSeenFrame = frame; + + if (projectileHandler.maxNanoParticles <= 0 || cfg.rate <= 0.0f) { + state.accumulator = 0.0f; + return 0; + } + + const float rate = std::max(0.0f, builderBuildSpeed) + * std::clamp(buildPower, 0.0f, 1.0f) + * (cfg.rate / ec.referenceBuildSpeed); + + const float accumulated = state.accumulator + rate; + int emitCount = static_cast(std::floor(accumulated)); + state.accumulator = accumulated - emitCount; + + /* A builder working at a tiny fraction of its buildpower can go a long time + * without the accumulator tipping over. Force a particle out occasionally so + * the player can still tell the job is progressing; the forced emit is + * debited, so the long-run rate stays proportional. */ + const int feedbackGap = std::max(1, static_cast(std::ceil(ec.feedbackEmitMinGap * ec.feedbackEmitReferenceRate / cfg.rate))); + if (emitCount == 0 && buildPower > 0.0f && (frame - state.lastEmitFrame) >= feedbackGap) { + emitCount = 1; + state.accumulator = 0.0f; + } + + if (emitCount > 0) + state.lastEmitFrame = frame; + + return emitCount; +} + +void Emitter::PruneEmitterStates(int frame) +{ + RECOIL_DETAILED_TRACY_ZONE; + if (frame < nextPruneFrame) + return; + + nextPruneFrame = frame + PRUNE_INTERVAL_FRAMES; + + const int maxIdleFrames = GetConfig().emission.emitterStateMaxIdleFrames; + const int contributorMaxAge = GetConfig().reclaimBurstParams.contributorMaxAge; + + for (auto it = emitterStates.begin(); it != emitterStates.end();) { + if (frame - it->second.lastSeenFrame > maxIdleFrames) + it = emitterStates.erase(it); + else + ++it; + } + + for (auto it = reclaimTrackers.begin(); it != reclaimTrackers.end();) { + if (frame - it->second.lastFrame > contributorMaxAge) + it = reclaimTrackers.erase(it); + else + ++it; + } +} + + +void Emitter::EmitBuilderSpray(CBuilder* builder, const float3& goal, float radius, bool inverse, bool highPriority, const CUnit* targetUnit, bool fadeWhenTargetComplete) +{ + RECOIL_DETAILED_TRACY_ZONE; + NanoPieceCache& nanoPieceCache = builder->GetNanoPieceCache(); + + /* Poll the script exactly once, as the legacy path does: this is the only + * part of a work tick the simulation can observe. */ + const int firstNanoPiece = nanoPieceCache.GetNanoPiece(builder->script); + + if (!builder->localModel.Initialized() || !builder->localModel.HasPiece(firstNanoPiece)) + return; + + PruneEmitterStates(gs->frameNum); + + const int emitCount = TakeEmitCount(builder->id, builder->unitDef->buildSpeed, nanoPieceCache.GetBuildPower()); + if (emitCount <= 0) + return; + + ForEachEmission(builder, nanoPieceCache.GetNanoPieces(), firstNanoPiece, emitCount, + [&](int modelNanoPiece, const float3& nanoPos) { + /* Outbound particles are bound to the target unit; inbound (reclaim) + * ones to the builder's own nano piece, since that is what they are + * converging on. Losing either fades the spray out. */ + const SpawnParams spawnParams = { + inverse ? static_cast(builder) : targetUnit, + inverse ? modelNanoPiece : -1, + inverse, + !inverse && fadeWhenTargetComplete, + }; + + projectileHandler.AddNanoParticle(nanoPos, goal, builder->unitDef, builder->team, radius, inverse, highPriority, spawnParams); + } + ); +} + +void Emitter::EmitFactorySpray(CFactory* factory, bool highPriority) +{ + RECOIL_DETAILED_TRACY_ZONE; + NanoPieceCache& nanoPieceCache = factory->GetNanoPieceCache(); + const int firstNanoPiece = nanoPieceCache.GetNanoPiece(factory->script); + + if (factory->curBuild == nullptr || !factory->localModel.Initialized() || !factory->localModel.HasPiece(firstNanoPiece)) + return; + + PruneEmitterStates(gs->frameNum); + + const int emitCount = TakeEmitCount(factory->id, factory->unitDef->buildSpeed, nanoPieceCache.GetBuildPower()); + if (emitCount <= 0) + return; + + ForEachEmission(factory, nanoPieceCache.GetNanoPieces(), firstNanoPiece, emitCount, + [&](int /*modelNanoPiece*/, const float3& nanoPos) { + projectileHandler.AddNanoParticle(nanoPos, factory->curBuild->midPos, factory->unitDef, factory->team, highPriority); + } + ); +} + + +void Emitter::RecordReclaimContributor(const CUnit* reclaimee, const CUnit* builder) +{ + RECOIL_DETAILED_TRACY_ZONE; + const int frame = gs->frameNum; + const int maxAge = GetConfig().reclaimBurstParams.contributorMaxAge; + + ReclaimTracker& tracker = reclaimTrackers[reclaimee->id]; + tracker.lastFrame = frame; + + const int oldestFrame = frame - maxAge; + for (std::size_t i = 0; i < tracker.contributors.size();) { + Contributor& contributor = tracker.contributors[i]; + + if (contributor.lastFrame < oldestFrame) { + contributor = tracker.contributors.back(); + tracker.contributors.pop_back(); + continue; + } + + if (contributor.unitID == builder->id && contributor.syncID == builder->GetSyncID()) { + contributor.lastFrame = frame; + return; + } + + ++i; + } + + tracker.contributors.push_back({builder->id, builder->GetSyncID(), frame}); +} + +int Emitter::GetBurstParticleCount(float reclaimedMetal, int contributorCount) const +{ + RECOIL_DETAILED_TRACY_ZONE; + const ReclaimBurstConfig& rb = GetConfig().reclaimBurstParams; + + /* Logarithmic in cost so a cheap unit still puffs and an expensive one does + * not swamp the particle budget, and sub-linear in reclaimer count so a + * coordinated swarm reads as bigger without scaling straight up. */ + const float scaledMetal = std::max(0.0f, reclaimedMetal); + const int perBuilder = rb.base + static_cast(std::floor(rb.logK * std::log(1.0f + scaledMetal / rb.logNorm) + 0.5f)); + const float contributorScale = std::pow(static_cast(std::max(1, contributorCount)), rb.builderExponent); + + return std::clamp(static_cast(std::floor(perBuilder * contributorScale + 0.5f)), 1, rb.maxParticles); +} + +void Emitter::EmitReclaimBurst(const CUnit* reclaimee, CUnit* finishingBuilder, float reclaimedMetal) +{ + RECOIL_DETAILED_TRACY_ZONE; + auto* reclaimBuilder = dynamic_cast(finishingBuilder); + if (reclaimee == nullptr || reclaimBuilder == nullptr) + return; + + const int frame = gs->frameNum; + const int maxAge = GetConfig().reclaimBurstParams.contributorMaxAge; + + std::vector contributors; + + if (const auto trackerIt = reclaimTrackers.find(reclaimee->id); trackerIt != reclaimTrackers.end()) { + for (const Contributor& contributor : trackerIt->second.contributors) { + if (contributor.lastFrame < frame - maxAge) + continue; + + CUnit* unit = unitHandler.GetUnit(contributor.unitID); + if (unit == nullptr || unit->GetSyncID() != contributor.syncID || unit->isDead || unit->team != reclaimBuilder->team) + continue; + + if (auto* contributorBuilder = dynamic_cast(unit); contributorBuilder != nullptr) + contributors.push_back(contributorBuilder); + } + + reclaimTrackers.erase(trackerIt); + } + + if (contributors.empty()) + contributors.push_back(reclaimBuilder); + + const int burstCount = GetBurstParticleCount(reclaimedMetal, static_cast(contributors.size())); + const int baseCount = burstCount / static_cast(contributors.size()); + const int remainder = burstCount - baseCount * static_cast(contributors.size()); + + for (std::size_t i = 0; i < contributors.size(); ++i) + EmitBuilderBurst(contributors[i], reclaimee, baseCount + (static_cast(i) < remainder)); +} + +void Emitter::EmitBuilderBurst(CBuilder* builder, const CUnit* reclaimee, int burstCount) +{ + RECOIL_DETAILED_TRACY_ZONE; + if (burstCount <= 0 || !builder->localModel.Initialized()) + return; + + const ReclaimBurstConfig& rb = GetConfig().reclaimBurstParams; + const std::vector& nanoPieces = builder->GetNanoPieceCache().GetNanoPieces(); + if (nanoPieces.empty()) + return; + + /* Spawn inside the reclaimee's collision volume rather than at its midpos, + * so the burst reads as the whole unit coming apart. */ + const float3& collisionScales = reclaimee->collisionVolume.GetScales(); + const float smallestCollisionScale = std::min(collisionScales.x, std::min(collisionScales.y, collisionScales.z)); + const float burstRadius = smallestCollisionScale * 0.5f * rb.volumeFraction; + if (burstRadius <= 0.0f) + return; + + const float3 burstCenter = reclaimee->midPos + reclaimee->collisionVolume.GetOffsets(); + const unsigned firstPiece = guRNG.NextInt(nanoPieces.size()); + + for (int i = 0; i < burstCount; ++i) { + const int modelNanoPiece = nanoPieces[(firstPiece + i) % nanoPieces.size()]; + if (!builder->localModel.HasPiece(modelNanoPiece)) + continue; + + const float3 nanoPos = builder->GetObjectSpacePos(builder->localModel.GetRawPiecePos(modelNanoPiece)); + const float3 burstPos = burstCenter + guRNG.NextVector() * burstRadius; + const float burstLength = fastmath::apxsqrt2((burstPos - nanoPos).SqLength()); + + if (burstLength < BURST_MIN_LENGTH) + continue; + + const SpawnParams spawnParams = {builder, modelNanoPiece, true, false}; + + projectileHandler.AddNanoParticle( + nanoPos, + burstPos, + builder->unitDef, + builder->team, + burstLength * rb.directionJitter, + true, + true, + spawnParams + ); + } +} + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleEmitter.h b/rts/Rendering/Env/NanoParticles/NanoParticleEmitter.h new file mode 100644 index 00000000000..9042e812698 --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleEmitter.h @@ -0,0 +1,86 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#pragma once + +#include +#include + +#include "System/UnorderedMap.hpp" + +class CBuilder; +class CFactory; +class CUnit; +struct float3; + +namespace NanoParticles { + +/* + * Decides how much spray a builder produces, and keeps the bookkeeping that + * needs for itself. + * + * Legacy nano spray emits exactly one particle per work tick, so a builder's + * visible output tracks its nano piece count rather than the work it is + * actually doing: a four-armed shipyard with modest buildpower outsprays a + * high-power constructor doing the same job. The effect instead emits + * proportionally to (buildSpeed * buildPower), which means a variable number of + * particles per tick and a fractional accumulator to carry the remainder. + * + * That accumulator, and the reclaim contributor tracking, live here rather than + * on CBuilder/CUnit: they are unsynced presentation state, they must not be + * serialised, and none of the sim needs to know they exist. + * + * Nothing here perturbs the simulation. The synced side of a work tick - the + * QueryNanoPiece script poll and its synced RNG draw - happens exactly once per + * tick either way, and every particle the effect adds is spawned from the + * unsynced RNG, as legacy nano spray already was. + */ +class Emitter { +public: + void Init(); + void Kill(); + + /// Builder emission for one work tick. Mirrors CBuilder::CreateNanoParticle's contract. + void EmitBuilderSpray(CBuilder* builder, const float3& goal, float radius, bool inverse, bool highPriority, const CUnit* targetUnit, bool fadeWhenTargetComplete); + + /// Factory emission for one work tick. Mirrors CFactory::CreateNanoParticle's contract. + void EmitFactorySpray(CFactory* factory, bool highPriority); + + /// Notes that `builder` poured reclaim into `reclaimee` this frame. + void RecordReclaimContributor(const CUnit* reclaimee, const CUnit* builder); + + /// Fires the completion burst from every builder that contributed to the reclaim. + void EmitReclaimBurst(const CUnit* reclaimee, CUnit* finishingBuilder, float reclaimedMetal); + +private: + struct EmitterState { + float accumulator = 0.0f; + int lastEmitFrame = 0; + int lastSeenFrame = 0; + }; + + struct Contributor { + int unitID = -1; + std::int64_t syncID = -1; + int lastFrame = -1; + }; + + struct ReclaimTracker { + std::vector contributors; + int lastFrame = -1; + }; + + int TakeEmitCount(int unitID, float builderBuildSpeed, float buildPower); + int GetBurstParticleCount(float reclaimedMetal, int contributorCount) const; + void EmitBuilderBurst(CBuilder* builder, const CUnit* reclaimee, int burstCount); + void PruneEmitterStates(int frame); + + spring::unordered_map emitterStates; + spring::unordered_map reclaimTrackers; + + /// Emitter-state sweeps are amortised rather than run every frame. + int nextPruneFrame = 0; +}; + +extern Emitter emitter; + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleRenderer.cpp b/rts/Rendering/Env/NanoParticles/NanoParticleRenderer.cpp new file mode 100644 index 00000000000..c79184278d4 --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleRenderer.cpp @@ -0,0 +1,733 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#include "NanoParticleRenderer.h" + +#include +#include +#include +#include + +#include "NanoParticleConfig.h" +#include "NanoParticleSystem.h" + +#include "Game/Camera.h" +#include "Game/GlobalUnsynced.h" +#include "Rendering/GlobalRendering.h" +#include "Rendering/GL/RenderBuffers.h" +#include "Rendering/GL/SubState.h" +#include "Rendering/Shaders/Shader.h" +#include "Rendering/Shaders/ShaderHandler.h" +#include "Sim/Misc/GlobalConstants.h" +#include "Sim/Misc/GlobalSynced.h" +#include "Sim/Misc/LosHandler.h" +#include "Sim/Misc/TeamHandler.h" +#include "Sim/Projectiles/Projectile.h" +#include "Rendering/Colors.h" +#include "System/Log/ILog.h" +#include "System/SafeUtil.h" + +#include "System/Misc/TracyDefs.h" + +namespace NanoParticles { + +Renderer* renderer = nullptr; + +namespace { + constexpr const char* SHADER_POOL = "[NanoParticles]"; + constexpr const char* SHADER_NAME_GEOM = "Nano Particles (geometry shader)"; + constexpr const char* SHADER_NAME_NOGEOM = "Nano Particles (instanced)"; + + /// Attribute slots. The no-geometry path prefixes the template mesh attributes. + constexpr GLuint ATTRIB_TEMPLATE_COUNT = 4; + + /// One vertex of the static mesh the no-geometry path instances. + struct TemplateVertex { + float3 position; + float3 normal; + float2 glowUV; + float isGlow; + }; + + /* + * A unit cube (6 quads) plus a camera-facing quad for the halo. The + * geometry shader builds the same thing on the fly; this is only needed + * where geometry shaders are not an option. + */ + const std::vector& GetTemplateVertices() + { + static const std::vector vertices = [] { + std::vector result; + result.reserve(6 * 6 + 6); + + const auto addTriangle = [&result](const float3& p0, const float3& p1, const float3& p2, const float3& normal) { + result.emplace_back(TemplateVertex{p0, normal, {0.0f, 0.0f}, 0.0f}); + result.emplace_back(TemplateVertex{p1, normal, {0.0f, 0.0f}, 0.0f}); + result.emplace_back(TemplateVertex{p2, normal, {0.0f, 0.0f}, 0.0f}); + }; + const auto addQuad = [&addTriangle](const float3& p0, const float3& p1, const float3& p2, const float3& p3, const float3& normal) { + addTriangle(p0, p1, p2, normal); + addTriangle(p0, p2, p3, normal); + }; + const auto addGlowVertex = [&result](float x, float y) { + result.emplace_back(TemplateVertex{ZeroVector, ZeroVector, {x, y}, 1.0f}); + }; + + addQuad({ 1.0f, -1.0f, -1.0f}, { 1.0f, 1.0f, -1.0f}, { 1.0f, 1.0f, 1.0f}, { 1.0f, -1.0f, 1.0f}, { 1.0f, 0.0f, 0.0f}); + addQuad({-1.0f, -1.0f, -1.0f}, {-1.0f, -1.0f, 1.0f}, {-1.0f, 1.0f, 1.0f}, {-1.0f, 1.0f, -1.0f}, {-1.0f, 0.0f, 0.0f}); + addQuad({-1.0f, 1.0f, -1.0f}, {-1.0f, 1.0f, 1.0f}, { 1.0f, 1.0f, 1.0f}, { 1.0f, 1.0f, -1.0f}, { 0.0f, 1.0f, 0.0f}); + addQuad({-1.0f, -1.0f, -1.0f}, { 1.0f, -1.0f, -1.0f}, { 1.0f, -1.0f, 1.0f}, {-1.0f, -1.0f, 1.0f}, { 0.0f, -1.0f, 0.0f}); + addQuad({-1.0f, -1.0f, 1.0f}, { 1.0f, -1.0f, 1.0f}, { 1.0f, 1.0f, 1.0f}, {-1.0f, 1.0f, 1.0f}, { 0.0f, 0.0f, 1.0f}); + addQuad({-1.0f, -1.0f, -1.0f}, {-1.0f, 1.0f, -1.0f}, { 1.0f, 1.0f, -1.0f}, { 1.0f, -1.0f, -1.0f}, { 0.0f, 0.0f, -1.0f}); + + addGlowVertex(-1.0f, -1.0f); + addGlowVertex( 1.0f, -1.0f); + addGlowVertex( 1.0f, 1.0f); + addGlowVertex(-1.0f, -1.0f); + addGlowVertex( 1.0f, 1.0f); + addGlowVertex(-1.0f, 1.0f); + return result; + }(); + + return vertices; + } + + /// All six planes; the effect never draws in the shadow pass. + constexpr std::uint8_t FRUSTUM_TEST_MASK = 0x3F; +} // namespace + + +void Renderer::InstanceBuffer::Kill() +{ + vbo.Release(); + vao.Delete(); + capacity = 0; + count = 0; +} + + +void Renderer::InitStatic() +{ + RECOIL_DETAILED_TRACY_ZONE; + KillStatic(); + + renderer = new Renderer(); + renderer->Init(); +} + +void Renderer::KillStatic() +{ + RECOIL_DETAILED_TRACY_ZONE; + if (renderer == nullptr) + return; + + renderer->Kill(); + spring::SafeDelete(renderer); +} + +void Draw(bool drawAboveWater, bool drawBelowWater, bool drawReflection, bool drawRefraction) +{ + if (renderer == nullptr) + return; + + renderer->Draw(drawAboveWater, drawBelowWater, drawReflection, drawRefraction); +} + +void DrawOnMinimap() +{ + if (renderer == nullptr) + return; + + renderer->DrawOnMinimap(); +} + +void Renderer::Init() +{ + RECOIL_DETAILED_TRACY_ZONE; + if (GetConfig().enabled) + InitShader(); +} + +void Renderer::Kill() +{ + RECOIL_DETAILED_TRACY_ZONE; + KillShader(); + + templateVBO.Release(); + instanceBuffer.Kill(); + + persistentVertices.clear(); + transientVertices.clear(); + enemyParticles.clear(); + enemyCells.clear(); + enemyCellIndices.clear(); + enemyCellCount = 0; + + uploadedGeneration = 0; + persistentDirty = true; + nextSyncFrame = 0; + syncedAllyTeam = -2; + syncedFullView = false; + gatheredValid = false; +} + +bool Renderer::Available() const +{ + return shader != nullptr && shader->IsValid(); +} + +void Renderer::ConfigChanged() +{ + RECOIL_DETAILED_TRACY_ZONE; + const Config& cfg = GetConfig(); + + // only rebuild when the config actually asks for a different path than the + // one we built; a missing geometry shader is a driver fact, not a config change + if (shader != nullptr && builtForceNoGeometryShader != cfg.forceNoGeometryShader) + KillShader(); + + if (cfg.enabled && shader == nullptr) + InitShader(); + + if (!cfg.enabled && shader != nullptr) + KillShader(); +} + + +bool Renderer::InitShader() +{ + RECOIL_DETAILED_TRACY_ZONE; + const Config& cfg = GetConfig(); + + usesGeometryShader = false; + builtForceNoGeometryShader = cfg.forceNoGeometryShader; + std::string geometryLog; + + if (!cfg.forceNoGeometryShader) { + shader = shaderHandler->CreateProgramObject(SHADER_POOL, SHADER_NAME_GEOM); + shader->AttachShaderObject(shaderHandler->CreateShaderObject("GLSL/NanoParticleVertProg.glsl", "", GL_VERTEX_SHADER)); + shader->AttachShaderObject(shaderHandler->CreateShaderObject("GLSL/NanoParticleGeomProg.glsl", "", GL_GEOMETRY_SHADER)); + shader->AttachShaderObject(shaderHandler->CreateShaderObject("GLSL/NanoParticleFragProg.glsl", "", GL_FRAGMENT_SHADER)); + shader->BindAttribLocation("particleStartPos", 0); + shader->BindAttribLocation("particleVelocity", 1); + shader->BindAttribLocation("particleFrames", 2); + shader->BindAttribLocation("particleColor", 3); + shader->Link(); + + if (shader->IsValid()) { + shader->Enable(); + shader->Disable(); + + if (shader->Validate()) { + usesGeometryShader = true; + shaderGeneration = cfg.generation; + SetShaderConfigUniforms(); + LOG_L(L_INFO, "[NanoParticles] geometry shader path initialized"); + return true; + } + } + + geometryLog = shader->GetLog(); + shaderHandler->ReleaseProgramObject(SHADER_POOL, SHADER_NAME_GEOM); + shader = nullptr; + } + + shader = shaderHandler->CreateProgramObject(SHADER_POOL, SHADER_NAME_NOGEOM); + shader->AttachShaderObject(shaderHandler->CreateShaderObject("GLSL/NanoParticleNoGeomVertProg.glsl", "", GL_VERTEX_SHADER)); + shader->AttachShaderObject(shaderHandler->CreateShaderObject("GLSL/NanoParticleFragProg.glsl", "", GL_FRAGMENT_SHADER)); + shader->BindAttribLocation("templatePosition", 0); + shader->BindAttribLocation("templateNormal", 1); + shader->BindAttribLocation("templateGlowUV", 2); + shader->BindAttribLocation("templateIsGlow", 3); + shader->BindAttribLocation("particleStartPos", 4); + shader->BindAttribLocation("particleVelocity", 5); + shader->BindAttribLocation("particleFrames", 6); + shader->BindAttribLocation("particleColor", 7); + shader->Link(); + + bool instancedValid = shader->IsValid(); + if (instancedValid) { + shader->Enable(); + shader->Disable(); + instancedValid = shader->Validate(); + } + + if (!instancedValid) { + LOG_L(L_WARNING, + "[NanoParticles] no usable shader path, falling back to legacy nano projectiles." + " geometry log:\n%s\ninstanced log:\n%s", + geometryLog.c_str(), shader->GetLog().c_str()); + shaderHandler->ReleaseProgramObject(SHADER_POOL, SHADER_NAME_NOGEOM); + shader = nullptr; + return false; + } + + if (cfg.forceNoGeometryShader) + LOG_L(L_INFO, "[NanoParticles] instanced path selected by NanoParticlesNoGeometryShader"); + else + LOG_L(L_WARNING, "[NanoParticles] geometry shader unavailable, using instanced path. log:\n%s", geometryLog.c_str()); + + shaderGeneration = cfg.generation; + SetShaderConfigUniforms(); + return true; +} + +void Renderer::KillShader() +{ + if (shader != nullptr) { + shaderHandler->ReleaseProgramObject(SHADER_POOL, usesGeometryShader ? SHADER_NAME_GEOM : SHADER_NAME_NOGEOM); + shader = nullptr; + } + + usesGeometryShader = false; + uniformGeneration = 0; + + // the VAO encodes which attribute layout the dead program expected + instanceBuffer.vao.Delete(); + + uploadedGeneration = 0; + persistentDirty = true; + gatheredValid = false; +} + +void Renderer::SetShaderConfigUniforms() +{ + RECOIL_DETAILED_TRACY_ZONE; + const AppearanceConfig& ap = GetConfig().appearance; + + shader->Enable(); + shader->SetUniform("drawRadius", ap.drawRadius); + shader->SetUniform("sizeVariation", ap.sizeVariation); + shader->SetUniform("baseAlpha", ap.baseAlpha); + shader->SetUniform("alphaVariation", ap.alphaVariation); + shader->SetUniform("glowScale", ap.glowScale); + shader->SetUniform("glowIntensity", ap.glowIntensity); + shader->SetUniform("glowFalloff", ap.glowFalloff); + shader->SetUniform("colorEqualize", ap.colorEqualize); + shader->SetUniform("colorTargetLuma", ap.colorTargetLuma); + shader->SetUniform("hueJitter", ap.hueJitter); + shader->SetUniform("coreBoost", ap.coreBoost); + shader->SetUniform("showInside", ap.showInside); + shader->SetUniform("noiseAmount", ap.noiseAmount); + shader->SetUniform("noiseScale", ap.noiseScale); + shader->SetUniform("whiteHotspot", ap.whiteHotspot); + shader->SetUniform("whiteHotspotThreshold", ap.whiteHotspotThreshold); + shader->SetUniform("rotationRange", ap.rotationRange); + // stated per second in the config, consumed per frame by the shader + shader->SetUniform("rotationRatePerFrame", ap.rotationRate / GAME_SPEED); + shader->SetUniform("noiseSpeedPerFrame", ap.noiseSpeed / GAME_SPEED); + shader->Disable(); + + uniformGeneration = GetConfig().generation; +} + + +void Renderer::EnsureTemplateBuffer() +{ + if (templateVBO.GetIdRaw() != 0) + return; + + templateVBO.Bind(); + templateVBO.New(GetTemplateVertices(), GL_STATIC_DRAW); + templateVBO.Unbind(); +} + +void Renderer::SetupInstanceVAO() +{ + RECOIL_DETAILED_TRACY_ZONE; + const GLuint instanceBase = usesGeometryShader ? 0 : ATTRIB_TEMPLATE_COUNT; + const GLuint attributeCount = instanceBase + 4; + + instanceBuffer.vao.Bind(); + + if (!usesGeometryShader) { + EnsureTemplateBuffer(); + templateVBO.Bind(); + + for (GLuint index = 0; index < ATTRIB_TEMPLATE_COUNT; ++index) { + glEnableVertexAttribArray(index); + glVertexAttribDivisor(index, 0); + } + + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(TemplateVertex), VA_TYPE_OFFSET(TemplateVertex, position)); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(TemplateVertex), VA_TYPE_OFFSET(TemplateVertex, normal)); + glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(TemplateVertex), VA_TYPE_OFFSET(TemplateVertex, glowUV)); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, sizeof(TemplateVertex), VA_TYPE_OFFSET(TemplateVertex, isGlow)); + } + + instanceBuffer.vbo.Bind(); + + // one instance per particle in the instanced path, one vertex per particle otherwise + const GLuint divisor = usesGeometryShader ? 0 : 1; + for (GLuint index = instanceBase; index < attributeCount; ++index) { + glEnableVertexAttribArray(index); + glVertexAttribDivisor(index, divisor); + } + + glVertexAttribPointer (instanceBase + 0, 3, GL_FLOAT, GL_FALSE, sizeof(InstanceVertex), VA_TYPE_OFFSET(InstanceVertex, startPos)); + glVertexAttribPointer (instanceBase + 1, 3, GL_FLOAT, GL_FALSE, sizeof(InstanceVertex), VA_TYPE_OFFSET(InstanceVertex, velocity)); + glVertexAttribPointer (instanceBase + 2, 4, GL_FLOAT, GL_FALSE, sizeof(InstanceVertex), VA_TYPE_OFFSET(InstanceVertex, frames)); + glVertexAttribPointer (instanceBase + 3, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(InstanceVertex), VA_TYPE_OFFSET(InstanceVertex, color)); + + instanceBuffer.vbo.Unbind(); + + if (!usesGeometryShader) + templateVBO.Unbind(); + + instanceBuffer.vao.Unbind(); + + for (GLuint index = 0; index < attributeCount; ++index) + glDisableVertexAttribArray(index); +} + +void Renderer::Upload(bool persistentChanged) +{ + ZoneScopedN("NanoParticles::Draw:Upload"); + RECOIL_DETAILED_TRACY_ZONE; + const std::size_t persistentCount = persistentVertices.size(); + const std::size_t transientCount = transientVertices.size(); + + instanceBuffer.count = persistentCount + transientCount; + + if (instanceBuffer.count == 0) + return; + + // a reallocation drops what was already in the buffer, so both halves go again + bool rewriteAll = persistentChanged; + + if (instanceBuffer.capacity < instanceBuffer.count) { + instanceBuffer.capacity = std::bit_ceil(instanceBuffer.count); + rewriteAll = true; + } + + instanceBuffer.vbo.Bind(); + + if (instanceBuffer.vbo.GetSize() < instanceBuffer.capacity * sizeof(InstanceVertex)) { + instanceBuffer.vbo.New(instanceBuffer.capacity * sizeof(InstanceVertex), GL_STREAM_DRAW); + rewriteAll = true; + } + + if (rewriteAll && persistentCount > 0) + instanceBuffer.vbo.SetBufferSubData(0, persistentCount * sizeof(InstanceVertex), persistentVertices.data()); + + // the enemy half trails the own/allied one so a single draw covers both + if (transientCount > 0) + instanceBuffer.vbo.SetBufferSubData(persistentCount * sizeof(InstanceVertex), transientCount * sizeof(InstanceVertex), transientVertices.data()); + + instanceBuffer.vbo.Unbind(); + + // the VAO records the buffer binding, so it has to come after the first New() + if (instanceBuffer.vao.GetIdRaw() == 0) + SetupInstanceVAO(); +} + +void Renderer::DrawOnMinimap() const +{ + ZoneScopedN("NanoParticles::DrawOnMinimap"); + RECOIL_DETAILED_TRACY_ZONE; + + if (!Available()) + return; + + /* Reuse what the world pass already filtered: both halves have had the + * ally/LOS work done, so this costs one walk and no visibility tests. The + * data can be a frame stale, which a minimap cannot show. */ + const RenderConfig& rc = GetConfig().render; + const float animationFrame = gs->frameNum + globalRendering->timeOffset; + const bool drawStreaks = (rc.minimapStreakFrames > 0.0f); + + auto& pointsRB = CProjectile::GetMiniMapPointsRB(); + + const auto addParticles = [&](const std::vector& vertices) { + for (const InstanceVertex& vertex: vertices) { + if (animationFrame >= vertex.frames.y) + continue; + + const float3 pos = vertex.startPos + vertex.velocity * (animationFrame - vertex.frames.z); + + if (drawStreaks) + CProjectile::AddMiniMapVertices({pos, color4::green}, {pos + vertex.velocity * rc.minimapStreakFrames, color4::green}); + + // a point survives any minimap scale; a sub-pixel streak does not + if (rc.minimapPoints) + pointsRB.AddVertex({pos, color4::green}); + } + }; + + addParticles(persistentVertices); + addParticles(transientVertices); +} + +void Renderer::DrawInstances() const +{ + if (instanceBuffer.count == 0 || instanceBuffer.vao.GetIdRaw() == 0) + return; + + instanceBuffer.vao.Bind(); + + if (usesGeometryShader) { + glDrawArrays(GL_POINTS, 0, static_cast(instanceBuffer.count)); + } else { + glDrawArraysInstanced(GL_TRIANGLES, 0, static_cast(GetTemplateVertices().size()), static_cast(instanceBuffer.count)); + } + + instanceBuffer.vao.Unbind(); +} + + +Renderer::InstanceVertex Renderer::MakeVertex(const Particle& particle) +{ + return InstanceVertex{ + particle.startPos, + particle.velocity, + { + static_cast(particle.createFrame), + static_cast(particle.deathFrame), + static_cast(particle.baseFrame), + particle.fadeFrames, + }, + particle.color, + }; +} + +void Renderer::RebuildAllyVisibility() +{ + const bool validViewer = teamHandler.IsValidAllyTeam(syncedAllyTeam); + + everythingVisible = syncedFullView || (validViewer && losHandler->GetGlobalLOS(syncedAllyTeam)); + + allyVisible.assign(std::max(0, teamHandler.ActiveAllyTeams()), std::uint8_t{0}); + + if (!validViewer) + return; + + for (std::size_t allyTeam = 0; allyTeam < allyVisible.size(); ++allyTeam) + allyVisible[allyTeam] = teamHandler.Ally(static_cast(allyTeam), syncedAllyTeam); +} + +void Renderer::SyncPersistentBuffer() +{ + ZoneScopedN("NanoParticles::Draw:Sync"); + RECOIL_DETAILED_TRACY_ZONE; + const std::uint32_t generation = system.GetGeneration(); + const int allyTeam = gu->myAllyTeam; + const bool fullView = gu->spectatingFullView; + const bool viewChanged = (syncedAllyTeam != allyTeam || syncedFullView != fullView); + + if (!viewChanged && uploadedGeneration == generation) + return; + + const int frame = gs->frameNum; + if (!viewChanged && frame < nextSyncFrame) + return; + + syncedAllyTeam = allyTeam; + syncedFullView = fullView; + + RebuildAllyVisibility(); + + const auto& particles = system.GetParticles(); + + persistentVertices.clear(); + persistentVertices.reserve(particles.size()); + enemyParticles.clear(); + + for (const Particle& particle : particles) { + const bool visible = everythingVisible + || (static_cast(particle.allyTeam) < allyVisible.size() && allyVisible[particle.allyTeam] != 0); + + if (visible) + persistentVertices.emplace_back(MakeVertex(particle)); + else + enemyParticles.emplace_back(particle); + } + + BuildEnemyCells(frame); + + persistentDirty = true; + uploadedGeneration = generation; + nextSyncFrame = frame + GetConfig().render.bufferSyncIntervalFrames; +} + +void Renderer::BuildEnemyCells(int frame) +{ + RECOIL_DETAILED_TRACY_ZONE; + const RenderConfig& rc = GetConfig().render; + + enemyCellIndices.clear(); + enemyCellCount = 0; + + if (enemyParticles.empty()) + return; + + enemyCellIndices.reserve(enemyParticles.size()); + + /* The bins have to stay valid until the next resync, so each cell's bounds + * cover where its particles will have travelled to by then. */ + const float lookaheadFrames = static_cast(rc.bufferSyncIntervalFrames + 1); + + for (std::uint32_t particleIndex = 0; particleIndex < enemyParticles.size(); ++particleIndex) { + const Particle& particle = enemyParticles[particleIndex]; + const float3 currentPos = particle.startPos + particle.velocity * static_cast(frame - particle.baseFrame); + const int cellX = static_cast(std::floor(currentPos.x / rc.enemyCellSize)); + const int cellZ = static_cast(std::floor(currentPos.z / rc.enemyCellSize)); + const auto cellKey = (static_cast(static_cast(cellX)) << 32u) | static_cast(cellZ); + + std::size_t cellIndex; + if (const auto it = enemyCellIndices.find(cellKey); it != enemyCellIndices.end()) { + cellIndex = it->second; + } else { + cellIndex = enemyCellCount++; + enemyCellIndices[cellKey] = cellIndex; + + if (cellIndex == enemyCells.size()) + enemyCells.emplace_back(); + + EnemyCell& newCell = enemyCells[cellIndex]; + newCell.particleIndices.clear(); + newCell.minPos = currentPos; + newCell.maxPos = currentPos; + } + + EnemyCell& cell = enemyCells[cellIndex]; + const float3 endPos = currentPos + particle.velocity * lookaheadFrames; + + cell.minPos = float3::min(cell.minPos, float3::min(currentPos, endPos)); + cell.maxPos = float3::max(cell.maxPos, float3::max(currentPos, endPos)); + cell.particleIndices.emplace_back(particleIndex); + } +} + +void Renderer::GatherVisibleEnemies(int frame) +{ + ZoneScopedN("NanoParticles::Draw:Enemies"); + transientVertices.clear(); + + if (enemyParticles.empty() || gu->spectatingFullView) + return; + + const int allyTeam = gu->myAllyTeam; + if (!teamHandler.IsValidAllyTeam(allyTeam)) + return; + + const float cullRadius = GetConfig().render.cullRadius; + const bool globalLos = losHandler->GetGlobalLOS(allyTeam); + const ILosType& los = losHandler->los; + const CLosMap& losMap = los.losMaps[allyTeam]; + const CCamera::Frustum& frustum = camera->GetFrustum(); + + for (std::uint32_t cellIndex = 0; cellIndex < enemyCellCount; ++cellIndex) { + const EnemyCell& cell = enemyCells[cellIndex]; + const float3 cellCenter = (cell.minPos + cell.maxPos) * 0.5f; + const float cellRadius = (cell.maxPos - cell.minPos).Length() * 0.5f + cullRadius; + + if (!frustum.IntersectSphere(cellCenter, cellRadius, FRUSTUM_TEST_MASK)) + continue; + + for (const std::uint32_t particleIndex : cell.particleIndices) { + const Particle& particle = enemyParticles[particleIndex]; + + if (frame >= particle.deathFrame) + continue; + + const float3 simPos = particle.startPos + particle.velocity * static_cast(frame - particle.baseFrame); + if (!frustum.IntersectSphere(simPos, cullRadius, FRUSTUM_TEST_MASK)) + continue; + + if (!globalLos && losMap.At(los.PosToSquare(simPos)) == 0 && losMap.At(los.PosToSquare(simPos + particle.velocity)) == 0) + continue; + + transientVertices.emplace_back(MakeVertex(particle)); + } + } +} + + +void Renderer::Draw(bool drawAboveWater, bool drawBelowWater, bool drawReflection, bool drawRefraction) +{ + ZoneScopedN("NanoParticles::Draw"); + RECOIL_DETAILED_TRACY_ZONE; + + if (!Available()) + return; + + const Config& cfg = GetConfig(); + + /* Bail before any state setup: the reflection and refraction passes are half + * of the draws per frame, and the per-pass overhead dwarfs what the particles + * add to a water surface. */ + if ((drawReflection || drawRefraction) && !cfg.render.drawInWaterPasses) + return; + + if (uniformGeneration != cfg.generation) + SetShaderConfigUniforms(); + + const int frame = gs->frameNum; + const int camType = camera->GetCamType(); + + SyncPersistentBuffer(); + + /* Reuse the gathered enemy set across passes that share a camera: the + * above- and below-water passes differ only by clip plane and would + * otherwise redo the cull and the upload for identical vertex data. */ + if (!gatheredValid || gatheredDrawFrame != globalRendering->drawFrame || gatheredCamType != camType) { + GatherVisibleEnemies(frame); + Upload(persistentDirty); + + persistentDirty = false; + gatheredDrawFrame = globalRendering->drawFrame; + gatheredCamType = camType; + gatheredValid = true; + } else if (persistentDirty) { + Upload(true); + persistentDirty = false; + } + + if (instanceBuffer.count == 0) + return; + + static constexpr std::array clipPlanes[] { + { 0.0f, 0.0f, 0.0f, 0.0f}, // never used + { 0.0f, -1.0f, 0.0f, 0.0f}, + { 0.0f, 1.0f, 0.0f, 0.0f}, + { 0.0f, 0.0f, 0.0f, 1.0f} + }; + const auto& clipPlane = clipPlanes[1U * drawBelowWater + 2U * drawAboveWater]; + + using namespace GL::State; + auto state = GL::SubState( + Blending(GL_TRUE), + BlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA), + DepthTest(GL_TRUE), + DepthMask(GL_FALSE), + ClipDistance<0>(GL_TRUE), + // the shape is drawn from both sides; back faces are dimmed, not culled + Culling(GL_FALSE) + ); + + const float3& camPos = camera->GetPos(); + const float3& camRight = camera->GetRight(); + const float3& camUp = camera->GetUp(); + + /* Own and allied particles are not culled on the CPU - doing so would force + * a buffer rebuild every time the camera moves. The shader rejects them + * instead, before the expensive stage that expands one particle into a + * shape and a halo. */ + const CCamera::Frustum& frustum = camera->GetFrustum(); + + shader->Enable(); + shader->SetUniform("animationFrame", frame + globalRendering->timeOffset); + shader->SetUniform("cameraPos", camPos.x, camPos.y, camPos.z); + shader->SetUniform("cameraRight", camRight.x, camRight.y, camRight.z); + shader->SetUniform("cameraUp", camUp.x, camUp.y, camUp.z); + shader->SetUniform("clipPlane", clipPlane[0], clipPlane[1], clipPlane[2], clipPlane[3]); + shader->SetUniform4v("frustumPlanes", static_cast(frustum.planes.size()), &frustum.planes[0].x); + + { + ZoneScopedN("NanoParticles::Draw:Submit"); + DrawInstances(); + } + + shader->Disable(); +} + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleRenderer.h b/rts/Rendering/Env/NanoParticles/NanoParticleRenderer.h new file mode 100644 index 00000000000..b408dd312fc --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleRenderer.h @@ -0,0 +1,173 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#pragma once + +#include +#include +#include + +#include "NanoParticleDefs.h" + +#include "Rendering/GL/myGL.h" +#include "Rendering/GL/VAO.h" +#include "Rendering/GL/VBO.h" +#include "System/UnorderedMap.hpp" + +namespace Shader { + struct IProgramObject; +} + +namespace NanoParticles { + +/* + * Draws the particles the System owns. + * + * Two shader paths produce the same picture: + * - a geometry shader expands one point per particle into the shape and its + * halo. One vertex per particle, so it is the cheaper of the two; + * - where geometry shaders are unavailable (or NanoParticlesNoGeometryShader + * is set), the same maths runs in a vertex shader over an instanced + * template mesh. + * + * Particles are split by visibility rather than re-uploaded wholesale each + * frame. Own and allied particles cannot become invisible, so they live in a + * persistent buffer that is rebuilt on a fixed cadence; particles of other + * allyteams have to be LOS-tested continuously, so they are gathered into a + * transient buffer per draw. + */ +class Renderer { +public: + /* + * Heap-allocated after the GL context exists, like every other drawer in the + * engine, and for the same reason: a VBO's constructor calls + * VBO::IsSupported(), which latches the GLAD extension flags into + * function-local statics on its first call. Constructing one before GLAD has + * loaded would latch them all to false and silently turn every VBO in the + * process into a no-op. + */ + static void InitStatic(); + static void KillStatic(); + + /// True once a shader path is up and particles can actually be drawn. + bool Available() const; + + /// Re-reads the config-derived shader state; called after a live config change. + void ConfigChanged(); + + void Draw(bool drawAboveWater, bool drawBelowWater, bool drawReflection, bool drawRefraction); + + /// Adds this frame's visible particles to the shared projectile minimap buffer. + void DrawOnMinimap() const; + +private: + void Init(); + void Kill(); + + /// One particle as uploaded. Motion is reconstructed in the shader. + struct InstanceVertex { + float3 startPos; + float3 velocity; + /// x = createFrame (hash seed), y = deathFrame, z = baseFrame (motion origin), w = fadeFrames (ramp before death). + float4 frames; + SColor color; + }; + + /* + * One buffer holds both halves of the live set: the own/allied particles + * first, then the LOS-filtered enemy ones. They are refreshed on different + * cadences but live back to back, so the whole thing draws in a single call + * instead of one per half. + */ + struct InstanceBuffer { + VBO vbo{GL_ARRAY_BUFFER}; + VAO vao; + /// Vertices the buffer can hold. + std::size_t capacity = 0; + /// Vertices to draw: own/allied followed by enemy. + std::size_t count = 0; + + void Kill(); + }; + + /// Bucket of enemy particles sharing a map cell, so the frustum test is done per cell. + struct EnemyCell { + std::vector particleIndices; + float3 minPos; + float3 maxPos; + }; + + bool InitShader(); + void KillShader(); + void SetShaderConfigUniforms(); + + void EnsureTemplateBuffer(); + void SetupInstanceVAO(); + void Upload(bool persistentChanged); + void DrawInstances() const; + + static InstanceVertex MakeVertex(const Particle& particle); + void RebuildAllyVisibility(); + + void SyncPersistentBuffer(); + void BuildEnemyCells(int frame); + void GatherVisibleEnemies(int frame); + + Shader::IProgramObject* shader = nullptr; + bool usesGeometryShader = false; + /// What NanoParticlesNoGeometryShader said when the program was built. + bool builtForceNoGeometryShader = false; + /// Config generation the shader uniforms were last set from. + std::uint32_t uniformGeneration = 0; + /// Config generation the shader program itself was built for. + std::uint32_t shaderGeneration = 0; + + /// Static shape+halo mesh the no-geometry path instances. + VBO templateVBO{GL_ARRAY_BUFFER}; + + InstanceBuffer instanceBuffer; + + std::vector persistentVertices; + std::vector transientVertices; + + std::vector enemyParticles; + std::vector enemyCells; + spring::unordered_map enemyCellIndices; + std::size_t enemyCellCount = 0; + + /// Set when the own/allied half changed and has to be rewritten. + bool persistentDirty = true; + /// Particle-set generation the persistent half was built from. + std::uint32_t uploadedGeneration = 0; + int nextSyncFrame = 0; + /// Allyteam/spectator state the split was made for; a change forces a resync. + int syncedAllyTeam = -2; + bool syncedFullView = false; + + /* + * The own/enemy split is decided per particle over the whole live set, so the + * test has to be a single lookup rather than a walk through teamHandler and + * losHandler. Rebuilt once per sync. + */ + bool everythingVisible = false; + std::vector allyVisible; + + /* + * Draw() runs up to four times per rendered frame: above- and below-water + * from CWorldDrawer, plus the water reflection and refraction passes. The + * above/below pair differ only by clip plane and share a camera, so the + * gathered enemy set and its upload are reused rather than rebuilt. The + * water passes use a different camera and do rebuild. + */ + std::uint32_t gatheredDrawFrame = 0; + int gatheredCamType = -1; + bool gatheredValid = false; +}; + +/// Null until InitStatic(); headless and pre-GL code must tolerate that. +extern Renderer* renderer; + +/// Null-safe wrappers for the draw call sites. +void Draw(bool drawAboveWater, bool drawBelowWater, bool drawReflection, bool drawRefraction); +void DrawOnMinimap(); + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleSystem.cpp b/rts/Rendering/Env/NanoParticles/NanoParticleSystem.cpp new file mode 100644 index 00000000000..37383a94069 --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleSystem.cpp @@ -0,0 +1,990 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#include "NanoParticleSystem.h" + +#include +#include +#include +#include + +#include "NanoParticleConfig.h" +#include "NanoParticleEmitter.h" +#include "NanoParticleRenderer.h" + +#include "Game/GlobalUnsynced.h" +#include "System/Config/ConfigHandler.h" +#include "Map/Ground.h" +#include "Map/ReadMap.h" +#include "Sim/Misc/GlobalSynced.h" +#include "Sim/Misc/LosHandler.h" +#include "Sim/Misc/TeamHandler.h" +#include "Sim/Projectiles/ProjectileHandler.h" +#include "Sim/Units/Unit.h" +#include "Sim/Units/UnitDef.h" +#include "Sim/Units/UnitHandler.h" +#include "System/EventHandler.h" + +#include "System/Misc/TracyDefs.h" + +namespace NanoParticles { + +System system; + +namespace { + /* + * Homing particles chase a target that may be a fast-moving unit. Without a + * ceiling on the correction they would visibly slingshot; these multiply the + * spawn speed to give the cap. + */ + constexpr float HOMING_SPEED_LIMIT_MULT_AIR = 1.35f; + constexpr float HOMING_SPEED_LIMIT_MULT_GROUND = 2.0f; + /// A target has to move at least this far (elmos) before a re-aim is worth it. + constexpr float HOMING_MIN_TARGET_MOVE_SQ = 1.0f; + /// Ordinary spray may spend this much of the budget; bursts may spend all of it. + constexpr float NORMAL_SPRAY_BUDGET_FRACTION = 0.95f; + /// The clamped route's peak is kept away from the endpoints so both legs stay sane. + constexpr float GROUND_CLAMP_PEAK_MIN = 0.15f; + constexpr float GROUND_CLAMP_PEAK_MAX = 0.85f; + + // Hash multipliers for the spatial caches below. Arbitrary large primes. + constexpr std::uint32_t HASH_X = 73856093u; + constexpr std::uint32_t HASH_Y = 19349663u; + constexpr std::uint32_t HASH_Z = 83492791u; + constexpr std::uint32_t HASH_W = 2654435761u; + + int Quantize(float value, float cellSize) + { + return static_cast(std::floor(value / cellSize + 0.5f)); + } + + /* + * Ground height, plus the clamp margin, cached for one frame. Route + * evaluation samples the same few columns repeatedly, so even a + * single-frame cache removes most of the heightmap reads. + */ + struct GroundHeightCache { + std::vector keys; + std::vector stamps; + std::vector heights; + const CReadMap* map = nullptr; + int lastFrame = -1; + + void Reset(std::uint32_t slots) + { + keys.assign(slots, 0); + stamps.assign(slots, std::numeric_limits::max()); + heights.assign(slots, 0.0f); + } + }; + + GroundHeightCache groundHeightCache; + + float GetGroundYMargin(float x, float z, int frame) + { + const Config& cfg = GetConfig(); + const GroundClampConfig& gc = cfg.groundClampParams; + + if (groundHeightCache.keys.size() != gc.heightCacheSlots) + groundHeightCache.Reset(gc.heightCacheSlots); + + if (groundHeightCache.map != readMap || frame < groundHeightCache.lastFrame) { + std::fill(groundHeightCache.stamps.begin(), groundHeightCache.stamps.end(), std::numeric_limits::max()); + groundHeightCache.map = readMap; + } + groundHeightCache.lastFrame = frame; + + const int quantizedX = Quantize(x, gc.heightCacheCellSize); + const int quantizedZ = Quantize(z, gc.heightCacheCellSize); + const auto key = (static_cast(static_cast(quantizedX)) << 32u) | static_cast(quantizedZ); + const std::size_t slot = (static_cast(quantizedX) * HASH_X ^ static_cast(quantizedZ) * HASH_Y) & (gc.heightCacheSlots - 1); + const auto stamp = static_cast(frame); + + if (groundHeightCache.stamps[slot] == stamp && groundHeightCache.keys[slot] == key) + return groundHeightCache.heights[slot]; + + const float height = CGround::GetHeightReal(x, z, false) + gc.margin; + groundHeightCache.keys[slot] = key; + groundHeightCache.stamps[slot] = stamp; + groundHeightCache.heights[slot] = height; + return height; + } + + /* + * Whether a straight path from start to end would sink into terrain, and if + * so how high it has to be lifted and where the worst dip is. Keyed on the + * quantised endpoints, because a builder pouring into one spot asks the same + * question for every particle it emits. + */ + struct RouteCacheEntry { + std::array endpoints = {}; + int expiresFrame = -1; + float guideY = 0.0f; + float peakT = 0.5f; + bool requiresClamp = false; + }; + + struct RouteCache { + std::vector entries; + const CReadMap* map = nullptr; + int lastFrame = -1; + }; + + RouteCache routeCache; + + bool EvaluateGroundClampRoute(const float3& startPos, const float3& finalPos, float& guideY, float& peakT) + { + RECOIL_DETAILED_TRACY_ZONE; + const Config& cfg = GetConfig(); + const GroundClampConfig& gc = cfg.groundClampParams; + const int frame = gs->frameNum; + const float3 path = finalPos - startPos; + + const std::array endpoints = { + Quantize(startPos.x, gc.routeCacheCellSize), + Quantize(startPos.y, gc.routeCacheCellSize), + Quantize(startPos.z, gc.routeCacheCellSize), + Quantize(finalPos.x, gc.routeCacheCellSize), + Quantize(finalPos.y, gc.routeCacheCellSize), + Quantize(finalPos.z, gc.routeCacheCellSize), + }; + const std::size_t slot = ( + static_cast(endpoints[0]) * HASH_X ^ + static_cast(endpoints[1]) * HASH_Y ^ + static_cast(endpoints[2]) * HASH_Z ^ + static_cast(endpoints[3]) * HASH_W ^ + static_cast(endpoints[4]) * 97531u ^ + static_cast(endpoints[5]) * 1099511627u + ) & (gc.routeCacheSlots - 1); + + if (routeCache.entries.size() != gc.routeCacheSlots) + routeCache.entries.assign(gc.routeCacheSlots, RouteCacheEntry{}); + + if (routeCache.map != readMap || frame < routeCache.lastFrame) { + for (RouteCacheEntry& entry : routeCache.entries) + entry.expiresFrame = -1; + routeCache.map = readMap; + } + routeCache.lastFrame = frame; + + RouteCacheEntry& cacheEntry = routeCache.entries[slot]; + if (cacheEntry.expiresFrame > frame && cacheEntry.endpoints == endpoints) { + if (!cacheEntry.requiresClamp) + return false; + + guideY = cacheEntry.guideY; + peakT = cacheEntry.peakT; + return true; + } + + const float horizontalLengthSq = path.x * path.x + path.z * path.z; + const bool longPath = (horizontalLengthSq > gc.longPathThresholdSq); + const std::size_t sampleCount = longPath ? gc.longSamples.size() : gc.shortSamples.size(); + + guideY = -std::numeric_limits::max(); + float maxPenetration = -std::numeric_limits::max(); + peakT = 0.5f; + + for (std::size_t sample = 0; sample < sampleCount; ++sample) { + const float t = longPath ? gc.longSamples[sample] : gc.shortSamples[sample]; + const float3 samplePos = startPos + path * t; + const float groundY = GetGroundYMargin(samplePos.x, samplePos.z, frame); + guideY = std::max(guideY, groundY); + + const float penetration = groundY - samplePos.y; + if (penetration > maxPenetration) { + maxPenetration = penetration; + peakT = t; + } + } + + cacheEntry.endpoints = endpoints; + cacheEntry.expiresFrame = frame + gc.routeCacheFrames; + cacheEntry.requiresClamp = (maxPenetration > gc.smartDelta); + + if (!cacheEntry.requiresClamp) + return false; + + cacheEntry.guideY = guideY; + cacheEntry.peakT = peakT; + return true; + } + + /* + * Homing targets are shared: every particle in one builder's stream chases + * the same unit or nano piece, so resolving it once per frame collapses + * hundreds of unit lookups into one. + */ + struct HomingTargetCacheEntry { + int frame = -1; + int targetID = -1; + std::int64_t targetSyncID = -1; + int targetPiece = -2; + float3 pos; + bool valid = false; + }; + + std::vector homingTargetCache; + + bool GetHomingTargetPos(int targetID, std::int64_t targetSyncID, int targetPiece, float3& targetPos) + { + RECOIL_DETAILED_TRACY_ZONE; + const HomingConfig& hc = GetConfig().homingParams; + + if (homingTargetCache.size() != hc.targetCacheSlots) + homingTargetCache.assign(hc.targetCacheSlots, HomingTargetCacheEntry{}); + + const std::size_t slot = ( + static_cast(targetID) * HASH_X ^ + static_cast(targetPiece + 2) * HASH_Y + ) & (hc.targetCacheSlots - 1); + HomingTargetCacheEntry& entry = homingTargetCache[slot]; + const int frame = gs->frameNum; + + if (entry.frame == frame && entry.targetID == targetID && entry.targetSyncID == targetSyncID && entry.targetPiece == targetPiece) { + if (entry.valid) + targetPos = entry.pos; + + return entry.valid; + } + + entry.frame = frame; + entry.targetID = targetID; + entry.targetSyncID = targetSyncID; + entry.targetPiece = targetPiece; + entry.valid = false; + + const CUnit* target = unitHandler.GetUnit(targetID); + if (target == nullptr || target->GetSyncID() != targetSyncID || target->isDead || target->IsCrashing()) + return false; + + if (targetPiece >= 0) { + if (!target->localModel.Initialized() || !target->localModel.HasPiece(targetPiece)) + return false; + + entry.pos = target->GetObjectSpacePos(target->localModel.GetRawPiecePos(targetPiece)); + } else { + entry.pos = target->midPos; + } + + entry.valid = true; + targetPos = entry.pos; + return true; + } + + /* + * Whether a bound target has been lost, or has had its work finished. Every + * particle in a stream asks about the same unit, so the unit lookup and the + * health read are done once per target per frame and the particles share it. + */ + struct TargetStateCacheEntry { + int frame = -1; + int targetID = -1; + std::int64_t targetSyncID = -1; + /// Destroyed, cancelled, recycled, or crashing: nothing left to spray at. + bool lost = false; + /// Finished and at full health: the work this spray represents is done. + bool complete = false; + }; + + std::vector targetStateCache; + + const TargetStateCacheEntry& GetTargetState(int targetID, std::int64_t targetSyncID) + { + RECOIL_DETAILED_TRACY_ZONE; + const TargetLostFadeConfig& fc = GetConfig().targetLostFadeParams; + + if (targetStateCache.size() != fc.cacheSlots) + targetStateCache.assign(fc.cacheSlots, TargetStateCacheEntry{}); + + const std::size_t slot = (static_cast(targetID) * HASH_X) & (fc.cacheSlots - 1); + TargetStateCacheEntry& entry = targetStateCache[slot]; + const int frame = gs->frameNum; + + if (entry.frame == frame && entry.targetID == targetID && entry.targetSyncID == targetSyncID) + return entry; + + entry.frame = frame; + entry.targetID = targetID; + entry.targetSyncID = targetSyncID; + + const CUnit* target = unitHandler.GetUnit(targetID); + + entry.lost = (target == nullptr || target->GetSyncID() != targetSyncID || target->isDead || target->IsCrashing()); + entry.complete = !entry.lost && !target->beingBuilt && (target->health >= target->maxHealth); + return entry; + } + + /* + * LOS for particles belonging to another allyteam, cached per quantised + * position. Only used to decide what LuaUI is told about; the renderer does + * its own, tighter test against the LOS map. + */ + struct LosCacheEntry { + int x = 0; + int y = 0; + int z = 0; + int allyTeam = -1; + int expiresFrame = -1; + bool visible = false; + }; + + struct LosCache { + std::vector entries; + int lastFrame = -1; + }; + + LosCache losCache; + + void InvalidateLosCache() + { + for (LosCacheEntry& entry: losCache.entries) + entry.expiresFrame = -1; + } + + bool IsPosInLos(const float3& pos, int allyTeam) + { + const VisibilityConfig& vc = GetConfig().visibility; + const int frame = gs->frameNum; + + if (losCache.entries.size() != vc.losCacheSlots) + losCache.entries.assign(vc.losCacheSlots, LosCacheEntry{}); + + if (frame < losCache.lastFrame) { + for (LosCacheEntry& entry : losCache.entries) + entry.expiresFrame = -1; + } + losCache.lastFrame = frame; + + const int x = Quantize(pos.x, vc.losCacheCellSize); + const int y = Quantize(pos.y, vc.losCacheCellSize); + const int z = Quantize(pos.z, vc.losCacheCellSize); + const std::size_t slot = ( + static_cast(x) * HASH_X ^ + static_cast(y) * HASH_Y ^ + static_cast(z) * HASH_Z ^ + static_cast(allyTeam) * HASH_W + ) & (vc.losCacheSlots - 1); + + LosCacheEntry& entry = losCache.entries[slot]; + if (entry.expiresFrame > frame && entry.x == x && entry.y == y && entry.z == z && entry.allyTeam == allyTeam) + return entry.visible; + + entry.x = x; + entry.y = y; + entry.z = z; + entry.allyTeam = allyTeam; + entry.expiresFrame = frame + vc.losCacheFrames; + entry.visible = losHandler->InLos(pos, allyTeam); + return entry.visible; + } + + float3 PositionAt(const Particle& particle, int frame) + { + return particle.startPos + particle.velocity * static_cast(frame - particle.baseFrame); + } +} // namespace + + +void System::Init() +{ + RECOIL_DETAILED_TRACY_ZONE; + projectileHandler.currentNanoParticles -= static_cast(particles.size()); + + particles.clear(); + particles.reserve(std::max(0, projectileHandler.maxNanoParticles)); + events.clear(); + events.reserve(GetConfig().luaUpdate.threadQueueReserve); + + nextParticleID = -1; + generation = 1; + luaSampleGeneration = 1; + luaClientActive = false; + // a fresh game must not leave a reloaded widget holding stale lightIDs + luaResetPending = true; + + groundHeightCache = {}; + routeCache = {}; + homingTargetCache.clear(); + targetStateCache.clear(); + losCache = {}; +} + +void System::Kill() +{ + RECOIL_DETAILED_TRACY_ZONE; + projectileHandler.currentNanoParticles -= static_cast(particles.size()); + + particles.clear(); + events.clear(); + homingTargetCache.clear(); + targetStateCache.clear(); + groundHeightCache = {}; + routeCache = {}; + losCache = {}; +} + +bool System::Enabled() const +{ + return GetConfig().enabled && renderer != nullptr && renderer->Available(); +} + +void System::ResetLuaUpdates() +{ + ++luaSampleGeneration; + luaResetPending = true; +} + + +bool System::AllowSpawn(bool highPriority) const +{ + const Config& cfg = GetConfig(); + const int maxParticles = projectileHandler.maxNanoParticles; + + if (maxParticles <= 0) + return false; + + /* High-priority emissions (capture, reclaim bursts) get the whole budget; + * ordinary spray is held slightly below it so the two never starve. */ + const float budget = maxParticles * (highPriority ? 1.0f : NORMAL_SPRAY_BUDGET_FRACTION); + const float used = particles.size() / std::max(1.0f, budget); + + if (used < cfg.emission.budgetSoftStart) + return true; + + if (used >= 1.0f) + return false; + + const float acceptProbability = (1.0f - used) / std::max(0.01f, 1.0f - cfg.emission.budgetSoftStart); + return (guRNG.NextFloat() < acceptProbability); +} + +void System::SpawnSpray( + const float3& startPos, + const float3& direction, + float distance, + float jitterFraction, + const SColor& color, + int teamNum, + float builderBuildSpeed, + bool inverse, + const SpawnParams& params +) { + RECOIL_DETAILED_TRACY_ZONE; + const EmissionConfig& ec = GetConfig().emission; + + const float3 sprayDirection = direction + guRNG.NextVector() * (jitterFraction * ec.directionJitterScale); + const int lifeTime = std::max(1, static_cast(std::ceil(distance / ec.particleSpeed))); + const int allyTeam = teamHandler.IsValidTeam(teamNum) ? teamHandler.AllyTeam(teamNum) : -1; + + // an inverse spray starts where a forward one would end, and runs backwards + const float3 spawnPos = inverse ? (startPos + sprayDirection * distance) : startPos; + const float3 velocity = (inverse ? -sprayDirection : sprayDirection) * ec.particleSpeed; + + Add(spawnPos, velocity, lifeTime, color, allyTeam, builderBuildSpeed, params); +} + +void System::Add( + const float3& startPos, + const float3& velocity, + int lifeTime, + const SColor& color, + int allyTeam, + float builderBuildSpeed, + const SpawnParams& params +) { + RECOIL_DETAILED_TRACY_ZONE; + const Config& cfg = GetConfig(); + const int frame = gs->frameNum; + + /* Per-particle speed spread. The endpoint is preserved and the lifetime + * adjusted to match, so a faster particle simply arrives sooner. */ + const float3 endPos = startPos + velocity * static_cast(lifeTime); + const float speedMultiplier = 1.0f + cfg.emission.speedVariation * (guRNG.NextFloat() * 2.0f - 1.0f); + const int adjustedLifeTime = std::max(1, static_cast(std::ceil(lifeTime / speedMultiplier))); + + if (nextParticleID == std::numeric_limits::min()) + nextParticleID = -1; + + Particle particle; + particle.startPos = startPos; + particle.velocity = (endPos - startPos) / static_cast(adjustedLifeTime); + particle.color = color; + particle.baseFrame = frame; + particle.createFrame = frame; + particle.deathFrame = frame + adjustedLifeTime; + particle.arriveFrame = particle.deathFrame; + particle.id = nextParticleID--; + particle.allyTeam = allyTeam; + particle.builderBuildSpeed = builderBuildSpeed; + particle.updatePhase = static_cast(static_cast(-particle.id)); + + if (params.target != nullptr) { + /* Bound regardless of homing, so the target-lost fade can watch it. A + * piece that does not exist on the model falls back to the midpos. */ + particle.targetID = params.target->id; + particle.targetSyncID = params.target->GetSyncID(); + particle.targetPiece = params.targetPiece; + particle.fadeWhenTargetComplete = params.fadeWhenTargetComplete; + + if (particle.targetPiece >= 0 && (!params.target->localModel.Initialized() || !params.target->localModel.HasPiece(particle.targetPiece))) + particle.targetPiece = -1; + + /* A unit still under construction has not left the factory pad yet; + * homing onto it would make the spray chase it as it rolls out. */ + if (cfg.homing && (params.inverse || !params.target->beingBuilt)) + InitHoming(particle, params.target, adjustedLifeTime); + } + + particle.fadeFrames = cfg.appearance.fadeFrames; + + if (cfg.groundClamp) + InitGroundClamp(particle, params.inverse, adjustedLifeTime); + + particle.luaSampleGeneration = luaSampleGeneration; + particle.luaSelected = ShouldReportToLua(particle); + + particles.emplace_back(particle); + projectileHandler.currentNanoParticles += 1; + + if (++generation == 0) + generation = 1; +} + + +void System::InitHoming(Particle& particle, const CUnit* target, int lifeTime) +{ + RECOIL_DETAILED_TRACY_ZONE; + const int targetPiece = particle.targetPiece; + + float3 targetPos = (targetPiece >= 0) + ? target->GetObjectSpacePos(target->localModel.GetRawPiecePos(targetPiece)) + : static_cast(target->midPos); + + particle.homing = true; + + if (targetPiece < 0) { + /* Aiming at the midpos would funnel every particle of a stream into one + * point. Keep the spread the emitter picked by tracking the offset from + * the midpos rather than the midpos itself. */ + const float3 initialEndPos = particle.startPos + particle.velocity * static_cast(lifeTime); + particle.homingOffset = initialEndPos - targetPos; + targetPos += particle.homingOffset; + + const float initialSpeed = particle.velocity.Length(); + const float speedMultiplier = (target->unitDef != nullptr && target->unitDef->canfly) + ? HOMING_SPEED_LIMIT_MULT_AIR + : HOMING_SPEED_LIMIT_MULT_GROUND; + const float maxHomingSpeed = std::max(initialSpeed * speedMultiplier, 0.1f); + particle.homingSpeedLimitSq = maxHomingSpeed * maxHomingSpeed; + } + + Reaim(particle, particle.startPos, targetPos, particle.baseFrame, lifeTime); +} + +void System::InitGroundClamp(Particle& particle, bool inverse, int lifeTime) +{ + RECOIL_DETAILED_TRACY_ZONE; + if (lifeTime <= 1) + return; + + const GroundClampConfig& gc = GetConfig().groundClampParams; + const int frame = particle.baseFrame; + + particle.groundClampFinalPos = particle.startPos + particle.velocity * static_cast(lifeTime); + + float guideY; + float peakT; + if (!EvaluateGroundClampRoute(particle.startPos, particle.groundClampFinalPos, guideY, peakT)) + return; + + particle.groundClamp = true; + particle.groundClampNextFrame = frame + (particle.updatePhase % gc.recheckFramesHit); + particle.groundClampFinalPos.y = std::max( + particle.groundClampFinalPos.y, + GetGroundYMargin(particle.groundClampFinalPos.x, particle.groundClampFinalPos.z, frame) + ); + + /* Reclaim-style particles converge on the builder, which is above ground by + * construction, so a lifted waypoint would only make them arc oddly. */ + if (inverse) + return; + + peakT = std::clamp(peakT, GROUND_CLAMP_PEAK_MIN, GROUND_CLAMP_PEAK_MAX); + + const int firstLegFrames = std::clamp(static_cast(lifeTime * peakT), 1, lifeTime - 1); + particle.groundClampWaypointPos = particle.startPos + (particle.groundClampFinalPos - particle.startPos) * peakT; + particle.groundClampWaypointPos.y = std::max(particle.groundClampWaypointPos.y, guideY); + particle.groundClampWaypointFrame = frame + firstLegFrames; + + Reaim(particle, particle.startPos, particle.groundClampWaypointPos, frame, firstLegFrames); +} + +void System::Reaim(Particle& particle, const float3& fromPos, const float3& targetPos, int frame, int remainingFrames) +{ + if (remainingFrames <= 0) + return; + + particle.startPos = fromPos; + particle.baseFrame = frame; + particle.velocity = (targetPos - fromPos) / static_cast(remainingFrames); +} + +bool System::ResolveHomingTarget(Particle& particle, float3& targetPos) const +{ + if (!particle.homing) + return false; + + if (!GetHomingTargetPos(particle.targetID, particle.targetSyncID, particle.targetPiece, targetPos)) { + particle.homing = false; + return false; + } + + if (particle.targetPiece < 0) + targetPos += particle.homingOffset; + + return true; +} + +bool System::UpdateGroundClamp(Particle& particle, const float3& currentPos, int frame) +{ + RECOIL_DETAILED_TRACY_ZONE; + const Config& cfg = GetConfig(); + const GroundClampConfig& gc = cfg.groundClampParams; + /* Paced against the arrival schedule: a fade may have pulled deathFrame + * earlier, and dividing the remaining path by that shortened window would + * accelerate the particle toward a target it is meant to dissolve short of. */ + const int remainingLife = particle.arriveFrame - frame; + + if (remainingLife <= 0) + return false; + + bool reaimed = false; + float3 pos = currentPos; + + // first leg done: turn toward the real endpoint + if (particle.groundClampWaypointFrame >= 0 && frame >= particle.groundClampWaypointFrame) { + particle.groundClampWaypointFrame = -1; + + float3 targetPos = particle.groundClampFinalPos; + if (cfg.homing) + ResolveHomingTarget(particle, targetPos); + + targetPos.y = std::max(targetPos.y, GetGroundYMargin(targetPos.x, targetPos.z, frame)); + Reaim(particle, pos, targetPos, frame, remainingLife); + reaimed = true; + } + + if (!cfg.groundClamp || frame < particle.groundClampNextFrame) + return reaimed; + + // still sinking into terrain? lift back out and re-aim from there + const float groundY = GetGroundYMargin(pos.x, pos.z, frame); + if (pos.y >= groundY) { + particle.groundClampNextFrame = frame + gc.recheckFramesMiss; + return reaimed; + } + + pos.y = groundY; + + const bool onFirstLeg = (particle.groundClampWaypointFrame >= 0); + float3 targetPos = onFirstLeg ? particle.groundClampWaypointPos : particle.groundClampFinalPos; + const int remainingFrames = onFirstLeg ? (particle.groundClampWaypointFrame - frame) : remainingLife; + + if (!onFirstLeg && cfg.homing) + ResolveHomingTarget(particle, targetPos); + + targetPos.y = std::max(targetPos.y, GetGroundYMargin(targetPos.x, targetPos.z, frame)); + Reaim(particle, pos, targetPos, frame, remainingFrames); + particle.groundClampNextFrame = frame + gc.recheckFramesHit; + return true; +} + +bool System::UpdateTargetLostFade(Particle& particle, int frame) +{ + RECOIL_DETAILED_TRACY_ZONE; + const Config& cfg = GetConfig(); + const TargetStateCacheEntry& state = GetTargetState(particle.targetID, particle.targetSyncID); + + if (!state.lost && !(particle.fadeWhenTargetComplete && state.complete)) + return false; + + const int remaining = particle.deathFrame - frame; + if (remaining <= 0) + return false; + + /* Each particle gets its own window so a stream dissolves unevenly instead + * of winking out on one frame. The window never extends a life, only + * shortens one, and the whole remainder becomes the ramp. */ + const TargetLostFadeConfig& fc = cfg.targetLostFadeParams; + const float jitter = fc.jitterMin + (fc.jitterMax - fc.jitterMin) * guRNG.NextFloat(); + const int fadeFrames = std::clamp(static_cast(fc.durationFrames * jitter), 1, remaining); + + particle.deathFrame = frame + fadeFrames; + particle.fadeFrames = static_cast(fadeFrames); + particle.fading = true; + /* Homing is left alone: a finished unit is still there to curve toward + * while the spray dissolves, and a destroyed one makes it give up on its + * own the next time it looks. */ + return true; +} + +bool System::UpdateHoming(Particle& particle, const float3& currentPos, int frame) +{ + RECOIL_DETAILED_TRACY_ZONE; + float3 targetPos; + if (!ResolveHomingTarget(particle, targetPos)) + return false; + + // arriveFrame, not deathFrame: see UpdateGroundClamp + const int remainingFrames = particle.arriveFrame - frame; + if (remainingFrames <= 1) + return false; + + const float3 newVelocity = (targetPos - currentPos) / static_cast(remainingFrames); + + // target ran away faster than a nano particle can plausibly chase; let it go + if (particle.homingSpeedLimitSq > 0.0f && newVelocity.SqLength() > particle.homingSpeedLimitSq) { + particle.homing = false; + return false; + } + + if ((newVelocity - particle.velocity).SqLength() * remainingFrames * remainingFrames < HOMING_MIN_TARGET_MOVE_SQ) + return false; + + particle.startPos = currentPos; + particle.baseFrame = frame; + particle.velocity = newVelocity; + return true; +} + + +void System::Update() +{ + ZoneScopedN("NanoParticles::Update"); + RECOIL_DETAILED_TRACY_ZONE; + + const Config& cfg = GetConfig(); + const int frame = gs->frameNum; + + const bool clientActive = eventHandler.HasNanoParticleUpdateClients(); + if (clientActive != luaClientActive) { + luaClientActive = clientActive; + ResetLuaUpdates(); + } + + /* Retract everything when the local player's visibility changes: a reported + * light lives until its remainingLife runs out, so without this a particle + * that just went out of sight keeps travelling on the consumer's side. */ + const int visibilityAllyTeam = gu->myAllyTeam; + const bool visibilityFullView = gu->spectatingFullView; + const bool visibilityGlobalLos = teamHandler.IsValidAllyTeam(visibilityAllyTeam) && losHandler->GetGlobalLOS(visibilityAllyTeam); + + if (visibilityAllyTeam != luaVisibilityAllyTeam || visibilityFullView != luaVisibilityFullView || visibilityGlobalLos != luaVisibilityGlobalLos) { + luaVisibilityAllyTeam = visibilityAllyTeam; + luaVisibilityFullView = visibilityFullView; + luaVisibilityGlobalLos = visibilityGlobalLos; + + // cached LOS answers were decided under the old basis + InvalidateLosCache(); + ResetLuaUpdates(); + } + + const bool reportToLua = cfg.luaUpdates && luaClientActive; + const std::uint32_t sampleGeneration = luaSampleGeneration; + + for (std::size_t i = 0; i < particles.size();) { + Particle& particle = particles[i]; + + if (frame >= particle.deathFrame) { + Remove(i); + continue; + } + + const float3 currentPos = PositionAt(particle, frame); + + const bool clampDue = particle.groundClamp && ( + (particle.groundClampWaypointFrame >= 0 && frame >= particle.groundClampWaypointFrame) || + (cfg.groundClamp && frame >= particle.groundClampNextFrame) + ); + /* Homing is staggered by particle so a large stream spreads its re-aims + * over the interval instead of spiking on one frame. */ + const bool homingDue = particle.homing + && cfg.homing + && particle.groundClampWaypointFrame < 0 + && ((frame + (particle.updatePhase % cfg.homingParams.runEveryFrames)) % cfg.homingParams.runEveryFrames) == 0; + + bool reaimed = false; + + if (clampDue) + reaimed = UpdateGroundClamp(particle, currentPos, frame); + + if (!reaimed && homingDue) + reaimed = UpdateHoming(particle, currentPos, frame); + + /* Staggered the same way as homing. Once a particle is fading there is + * nothing further to decide, so it drops out of the check entirely. */ + const bool fadeCheckDue = !particle.fading + && particle.targetID >= 0 + && cfg.targetLostFade + && ((frame + (particle.updatePhase % cfg.targetLostFadeParams.checkEveryFrames)) % cfg.targetLostFadeParams.checkEveryFrames) == 0; + + bool faded = false; + + if (fadeCheckDue) + faded = UpdateTargetLostFade(particle, frame); + + if ((reaimed || faded) && ++generation == 0) + generation = 1; + + if (reportToLua) { + // re-sample after a config change so the reported subset stays consistent + if (particle.luaSampleGeneration != sampleGeneration) { + particle.luaSampleGeneration = sampleGeneration; + particle.luaSelected = ShouldReportToLua(particle); + particle.luaSpawnReported = false; + } + + if (particle.luaSelected) { + if (!particle.luaSpawnReported) { + QueueEvent(particle, currentPos, EventType::Spawn); + particle.luaSpawnReported = true; + } else if (reaimed || faded) { + QueueEvent(particle, currentPos, EventType::Update); + } + } + } + + ++i; + } + + DispatchEvents(); +} + +void System::Remove(std::size_t index) +{ + particles[index] = particles.back(); + particles.pop_back(); + + projectileHandler.currentNanoParticles -= 1; + + if (++generation == 0) + generation = 1; +} + + +bool System::ShouldReportToLua(const Particle& particle) const +{ + const Config& cfg = GetConfig(); + const LuaUpdateConfig& lc = cfg.luaUpdate; + + /* + * Deferred-light widgets cannot afford one light per particle, so only a + * fraction is reported. The fraction scales with the emitter's throughput + * and the particle's speed, so a big builder still lights up more than a + * small one, and the pick itself is a hash of the particle id: stable + * across frames, and free of any per-particle RNG draw. + */ + const float sampleFraction = cfg.rate + * lc.sampleRate + * (std::max(0.0f, particle.builderBuildSpeed) / cfg.emission.referenceBuildSpeed) + * (particle.velocity.Length() / cfg.emission.particleSpeed); + + if (sampleFraction <= 0.0f) + return false; + if (sampleFraction >= 1.0f) + return true; + + const double hash = std::fmod(static_cast(static_cast(particle.id)) * lc.hashMultiplier, lc.hashRange); + return hash < (static_cast(sampleFraction) * lc.hashRange); +} + +bool System::IsEventVisible(const Particle& particle, const float3& pos) const +{ + if (gu->spectatingFullView) + return true; + + if (teamHandler.IsValidAllyTeam(particle.allyTeam) && teamHandler.Ally(particle.allyTeam, gu->myAllyTeam)) + return true; + + if (!teamHandler.IsValidAllyTeam(gu->myAllyTeam)) + return false; + + return IsPosInLos(pos, gu->myAllyTeam) || IsPosInLos(pos + particle.velocity, gu->myAllyTeam); +} + +void System::QueueEvent(const Particle& particle, const float3& pos, EventType type) +{ + if (!IsEventVisible(particle, pos)) + return; + + Event event; + event.type = type; + event.lightID = particle.id; + event.pos = pos; + event.velocity = particle.velocity; + event.remainingLife = static_cast(std::max(particle.deathFrame - gs->frameNum, 0)); + event.color = float3(particle.color[0], particle.color[1], particle.color[2]) * (1.0f / 255.0f); + event.builderBuildSpeed = particle.builderBuildSpeed; + events.emplace_back(event); +} + +void System::DispatchEvents() +{ + if (luaResetPending) { + Event reset; + reset.type = EventType::Reset; + events.insert(events.begin(), reset); + luaResetPending = false; + } + + if (events.empty()) + return; + + { + ZoneScopedN("NanoParticles::Update:LuaUI"); + eventHandler.NanoParticleUpdate(events); + } + + events.clear(); +} + + +namespace { + /* + * Springsettings observer. Lives here rather than on the System or Renderer + * because a change can affect either, and both have to be told in one place. + */ + struct ConfigObserver { + void ConfigNotify(const std::string& key, const std::string& /*value*/) + { + if (!ReloadConfigSetting(key)) + return; + + if (renderer != nullptr) + renderer->ConfigChanged(); + + system.ResetLuaUpdates(); + } + }; + + ConfigObserver configObserver; +} // namespace + +void Init() +{ + InitConfig(); + configHandler->NotifyOnChange(&configObserver, GetObservedConfigKeys()); + + system.Init(); + emitter.Init(); +} + +void Kill() +{ + configHandler->RemoveObserver(&configObserver); + + emitter.Kill(); + system.Kill(); +} + +} // namespace NanoParticles diff --git a/rts/Rendering/Env/NanoParticles/NanoParticleSystem.h b/rts/Rendering/Env/NanoParticles/NanoParticleSystem.h new file mode 100644 index 00000000000..c0c0c447f2f --- /dev/null +++ b/rts/Rendering/Env/NanoParticles/NanoParticleSystem.h @@ -0,0 +1,127 @@ +/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ + +#pragma once + +#include +#include +#include + +#include "NanoParticleDefs.h" + +namespace NanoParticles { + +/* + * Owns the live nano particles. + * + * The system is deliberately not a projectile container: particles have no + * projectile id, take no part in collision or quadfield work, are never handed + * to Lua as projectiles, and are not serialised. They exist only between the + * emitter that spawns them and the renderer that draws them. + * + * Update() runs once per sim frame, straight after the projectile handler, and + * is single-threaded: the whole live set is a flat vector of PODs, and at the + * default MaxNanoParticles a full pass is far cheaper than the synchronisation + * a parallel one would need. + */ +class System { +public: + void Init(); + void Kill(); + + /// True when the effect is configured on and the renderer can actually draw it. + bool Enabled() const; + + /* + * Adds one particle to a spray. + * + * `direction` is a unit vector along the un-jittered path and `jitterFraction` + * the spread the caller asked for, as a fraction of `distance`; the effect + * applies its own scale on top. `inverse` sprays run from the far end back + * toward `startPos`, which is how reclaim and capture read. + */ + void SpawnSpray( + const float3& startPos, + const float3& direction, + float distance, + float jitterFraction, + const SColor& color, + int teamNum, + float builderBuildSpeed, + bool inverse, + const SpawnParams& params = {} + ); + + /* + * Whether the budget has room for another particle. Stays fully open until + * `emission.budgetSoftStart` of the budget is spent, then ramps rejection in + * over the remainder, so NanoParticlesRate scales close to linearly instead + * of asymptoting the way legacy's proportional throttle does. + */ + bool AllowSpawn(bool highPriority) const; + + /// Per sim frame: re-aim homing/clamped particles, retire expired ones, tell LuaUI. + void Update(); + + /// Live particles, in no particular order. Read by the renderer. + const std::vector& GetParticles() const { return particles; } + + /// Bumped on every add, removal and re-aim, so the renderer can skip re-uploads. + std::uint32_t GetGeneration() const { return generation; } + + /// Invalidates every LuaUI light and re-samples which particles are reported. + void ResetLuaUpdates(); + +private: + void Add(const float3& startPos, const float3& velocity, int lifeTime, const SColor& color, int allyTeam, float builderBuildSpeed, const SpawnParams& params); + void InitHoming(Particle& particle, const CUnit* target, int lifeTime); + void InitGroundClamp(Particle& particle, bool inverse, int lifeTime); + bool ResolveHomingTarget(Particle& particle, float3& targetPos) const; + bool UpdateGroundClamp(Particle& particle, const float3& currentPos, int frame); + bool UpdateHoming(Particle& particle, const float3& currentPos, int frame); + bool UpdateTargetLostFade(Particle& particle, int frame); + static void Reaim(Particle& particle, const float3& fromPos, const float3& targetPos, int frame, int remainingFrames); + + bool ShouldReportToLua(const Particle& particle) const; + bool IsEventVisible(const Particle& particle, const float3& pos) const; + void QueueEvent(const Particle& particle, const float3& pos, EventType type); + void DispatchEvents(); + + void Remove(std::size_t index); + + std::vector particles; + std::vector events; + + /// Lua light ids count down from -1 so they can never collide with a unit id. + int nextParticleID = -1; + std::uint32_t generation = 1; + /// Bumped when the sampling changes; particles re-evaluate their selection. + std::uint32_t luaSampleGeneration = 1; + /// Whether any client is actually subscribed to the callin. + bool luaClientActive = false; + /// Set while a Reset event is pending, so it leads the next batch. + bool luaResetPending = false; + + /* + * What the local player could see when the current LuaUI reports were made. + * A consumer expires a light from the `remainingLife` it was given, so + * nothing retracts a light on its own; when visibility changes underneath + * it - spectator toggle, allyteam change, global LOS toggle - the whole set + * has to be reset and re-reported, or lights keep travelling for particles + * that just became invisible. + */ + int luaVisibilityAllyTeam = -2; + bool luaVisibilityFullView = false; + bool luaVisibilityGlobalLos = false; +}; + +extern System system; + +/* + * Brings the effect up and down as a whole: config, particle store, emitter + * state and the springsettings observer. The renderer is separate, because it + * needs a GL context and therefore a different point in the load order. + */ +void Init(); +void Kill(); + +} // namespace NanoParticles diff --git a/rts/Rendering/WorldDrawer.cpp b/rts/Rendering/WorldDrawer.cpp index 2ffeea052c1..cf77599b40f 100644 --- a/rts/Rendering/WorldDrawer.cpp +++ b/rts/Rendering/WorldDrawer.cpp @@ -20,6 +20,7 @@ #include "Rendering/LineDrawer.h" #include "Rendering/LuaObjectDrawer.h" #include "Rendering/Features/FeatureDrawer.h" +#include "Rendering/Env/NanoParticles/NanoParticleRenderer.h" #include "Rendering/Env/Particles/ProjectileDrawer.h" #include "Rendering/Units/UnitDrawer.h" #include "Rendering/IPathDrawer.h" @@ -137,6 +138,7 @@ void CWorldDrawer::InitPost() const CProjectileDrawer::InitStatic(); CUnitDrawer::InitStatic(); + NanoParticles::Renderer::InitStatic(); // see ::InitPre // CFeatureDrawer::InitStatic(); } @@ -184,6 +186,7 @@ void CWorldDrawer::Kill() CFeatureDrawer::KillStatic(gu->globalReload); CUnitDrawer::KillStatic(gu->globalReload); // depends on unitHandler, cubeMapHandler CProjectileDrawer::KillStatic(gu->globalReload); + NanoParticles::Renderer::KillStatic(); S3DModelVAO::Kill(); modelLoader.Kill(); @@ -413,6 +416,7 @@ void CWorldDrawer::DrawAlphaObjects() const SCOPED_TIMER("Draw::World::Particles"); SCOPED_GL_DEBUGGROUP("Draw::World::Particles"); projectileDrawer->DrawAlpha(!hasWaterRendering, true, false, false); + NanoParticles::Draw(!hasWaterRendering, true, false, false); if (hasWaterRendering) glDisable(GL_CLIP_PLANE3); @@ -452,6 +456,7 @@ void CWorldDrawer::DrawAlphaObjects() const SCOPED_TIMER("Draw::World::Particles"); SCOPED_GL_DEBUGGROUP("Draw::World::Particles"); projectileDrawer->DrawAlpha(true, false, false, false); + NanoParticles::Draw(true, false, false, false); glDisable(GL_CLIP_PLANE3); } diff --git a/rts/Sim/Projectiles/ProjectileHandler.cpp b/rts/Sim/Projectiles/ProjectileHandler.cpp index aa4ff0ca05a..6dbef48d24e 100644 --- a/rts/Sim/Projectiles/ProjectileHandler.cpp +++ b/rts/Sim/Projectiles/ProjectileHandler.cpp @@ -17,6 +17,7 @@ #include "Sim/Misc/GlobalSynced.h" #include "Sim/Misc/QuadField.h" #include "Sim/Misc/TeamHandler.h" +#include "Rendering/Env/NanoParticles/NanoParticleSystem.h" #include "Rendering/Env/Particles/Classes/NanoProjectile.h" #include "Sim/Projectiles/WeaponProjectiles/WeaponProjectile.h" #include "Sim/Units/Unit.h" @@ -39,6 +40,11 @@ #define NORMAL_NANO_PRIO 0.95f #define HIGH_NANO_PRIO 1.0f +// spread of an emission that did not come with a spread radius of its own +static constexpr float NANO_SPRAY_JITTER = 0.15f; +// elmos per frame a legacy nano projectile covers +static constexpr float NANO_PROJECTILE_SPEED = 3.0f; + CONFIG(int, MaxParticles).defaultValue(10000).headlessValue(0).minimumValue(0); CONFIG(int, MaxNanoParticles).defaultValue(2000).headlessValue(0).minimumValue(0); @@ -675,10 +681,7 @@ void CProjectileHandler::AddNanoParticle( bool highPriority ) { RECOIL_DETAILED_TRACY_ZONE; - const float priority = mix(NORMAL_NANO_PRIO, HIGH_NANO_PRIO, highPriority); - const float emitProb = 1.0f - GetNanoParticleSaturation(priority); - - if (emitProb < guRNG.NextFloat()) + if (!AllowNanoParticleSpawn(highPriority)) return; if (!unitDef->showNanoSpray) return; @@ -687,8 +690,33 @@ void CProjectileHandler::AddNanoParticle( const float l = fastmath::apxsqrt2(dif.SqLength()); dif /= l; - dif += (guRNG.NextVector() * 0.15f); + if (NanoParticles::system.Enabled()) { + NanoParticles::system.SpawnSpray(startPos, dif, l, NANO_SPRAY_JITTER, GetNanoParticleColor(unitDef, teamNum), teamNum, unitDef->buildSpeed, false); + return; + } + + dif += (guRNG.NextVector() * NANO_SPRAY_JITTER); + + projMemPool.alloc(startPos, dif, int(l), GetNanoParticleColor(unitDef, teamNum)); +} + +bool CProjectileHandler::AllowNanoParticleSpawn(bool highPriority) const +{ + RECOIL_DETAILED_TRACY_ZONE; + // the standalone effect budgets its own spawns; see NanoParticles::System::AllowSpawn + if (NanoParticles::system.Enabled()) + return NanoParticles::system.AllowSpawn(highPriority); + + const float priority = mix(NORMAL_NANO_PRIO, HIGH_NANO_PRIO, highPriority); + const float emitProb = 1.0f - GetNanoParticleSaturation(priority); + + return (emitProb >= guRNG.NextFloat()); +} + +SColor CProjectileHandler::GetNanoParticleColor(const UnitDef* unitDef, int teamNum) +{ + RECOIL_DETAILED_TRACY_ZONE; const float3 udColor = unitDef->nanoColor; constexpr float udAlpha = 20 / 256.0f; // denom=255 is not constexpr-able @@ -700,7 +728,7 @@ void CProjectileHandler::AddNanoParticle( {tColor[0], tColor[1], tColor[2], tAlpha}, }; - projMemPool.alloc(startPos, dif, int(l), colors[globalRendering->teamNanospray]); + return colors[globalRendering->teamNanospray]; } void CProjectileHandler::AddNanoParticle( @@ -710,38 +738,34 @@ void CProjectileHandler::AddNanoParticle( int teamNum, float radius, bool inverse, - bool highPriority + bool highPriority, + const NanoParticles::SpawnParams& spawnParams ) { RECOIL_DETAILED_TRACY_ZONE; - const float priority = mix(NORMAL_NANO_PRIO, HIGH_NANO_PRIO, highPriority); - const float emitProb = 1.0f - GetNanoParticleSaturation(priority); - - if (emitProb < guRNG.NextFloat()) + if (!AllowNanoParticleSpawn(highPriority)) return; if (!unitDef->showNanoSpray) return; float3 dif = endPos - startPos; const float len = fastmath::apxsqrt2(dif.SqLength()); + const float jitterFraction = radius / len; dif /= len; - dif += (guRNG.NextVector() * (radius / len)); - const float3 udColor = unitDef->nanoColor; - constexpr float udAlpha = 20 / 256.0f; + if (NanoParticles::system.Enabled()) { + NanoParticles::system.SpawnSpray(startPos, dif, len, jitterFraction, GetNanoParticleColor(unitDef, teamNum), teamNum, unitDef->buildSpeed, inverse, spawnParams); + return; + } - const uint8_t* tColor = (teamHandler.Team(teamNum))->color; - constexpr uint8_t tAlpha = udAlpha * 256; + dif += (guRNG.NextVector() * jitterFraction); - const SColor colors[2] = { - {udColor.r, udColor.g, udColor.b, udAlpha}, - {tColor[0], tColor[1], tColor[2], tAlpha}, - }; + const SColor color = GetNanoParticleColor(unitDef, teamNum); if (!inverse) { - projMemPool.alloc(startPos, dif * 3.0f, int(len / 3.0f), colors[globalRendering->teamNanospray]); + projMemPool.alloc(startPos, dif * NANO_PROJECTILE_SPEED, int(len / NANO_PROJECTILE_SPEED), color); } else { - projMemPool.alloc(startPos + dif * len, -dif * 3.0f, int(len / 3.0f), colors[globalRendering->teamNanospray]); + projMemPool.alloc(startPos + dif * len, -dif * NANO_PROJECTILE_SPEED, int(len / NANO_PROJECTILE_SPEED), color); } } diff --git a/rts/Sim/Projectiles/ProjectileHandler.h b/rts/Sim/Projectiles/ProjectileHandler.h index b4a823ecffd..2a0e99dde2c 100644 --- a/rts/Sim/Projectiles/ProjectileHandler.h +++ b/rts/Sim/Projectiles/ProjectileHandler.h @@ -7,6 +7,7 @@ #include #include "Rendering/Models/3DModelDefs.hpp" +#include "Rendering/Env/NanoParticles/NanoParticleDefs.h" #include "Rendering/Env/Particles/Classes/FlyingPiece.h" #include "System/float3.h" #include "System/FreeListMap.h" @@ -77,7 +78,11 @@ class CProjectileHandler const int2 renderParams ); void AddNanoParticle(const float3, const float3, const UnitDef*, int team, bool highPriority); - void AddNanoParticle(const float3, const float3, const UnitDef*, int team, float radius, bool inverse, bool highPriority); + /* `spawnParams` only reaches the standalone nano particle effect; legacy nano + * projectiles ignore it, as they have nowhere to put the extra context. */ + void AddNanoParticle(const float3, const float3, const UnitDef*, int team, float radius, bool inverse, bool highPriority, const NanoParticles::SpawnParams& spawnParams = {}); + static SColor GetNanoParticleColor(const UnitDef* unitDef, int teamNum); + bool AllowNanoParticleSpawn(bool highPriority) const; public: int maxParticles = 0; diff --git a/rts/Sim/Units/Unit.cpp b/rts/Sim/Units/Unit.cpp index 8511e6cc3dd..cc5bce8d41e 100644 --- a/rts/Sim/Units/Unit.cpp +++ b/rts/Sim/Units/Unit.cpp @@ -54,6 +54,9 @@ #include "Sim/MoveTypes/MoveType.h" #include "Sim/MoveTypes/MoveTypeFactory.h" #include "Sim/MoveTypes/ScriptMoveType.h" +#include "Rendering/Env/NanoParticles/NanoParticleConfig.h" +#include "Rendering/Env/NanoParticles/NanoParticleEmitter.h" +#include "Rendering/Env/NanoParticles/NanoParticleSystem.h" #include "Sim/Projectiles/FlareProjectile.h" #include "Sim/Projectiles/ProjectileMemPool.h" #include "Sim/Projectiles/WeaponProjectiles/MissileProjectile.h" @@ -2094,16 +2097,27 @@ bool CUnit::AddBuildPower(CUnit* builder, float amount) return false; } + const bool trackReclaimBurst = NanoParticles::GetConfig().reclaimBurst && NanoParticles::system.Enabled(); + + if (trackReclaimBurst) + NanoParticles::emitter.RecordReclaimContributor(this, builder); + // turn reclaimee into nanoframe (even living units) if (modInfo.reclaimUnitMethod == 0) TurnIntoNanoframe(); + // captured before buildProgress is overwritten below + const float reclaimBurstMetal = trackReclaimBurst ? cost.metal * buildProgress : 0.0f; + // reduce health & resources health = postHealth; buildProgress = postBuildProgress; // reclaim finished? if (killMe || buildProgress <= 0.0f || health <= 0.0f) { + if (trackReclaimBurst) + NanoParticles::emitter.EmitReclaimBurst(this, builder, reclaimBurstMetal); + health = 0.0f; buildProgress = 0.0f; KillUnit(builder, false, true, -CSolidObject::DAMAGE_RECLAIMED); diff --git a/rts/Sim/Units/UnitTypes/Builder.cpp b/rts/Sim/Units/UnitTypes/Builder.cpp index deb8c846b73..8559220c142 100644 --- a/rts/Sim/Units/UnitTypes/Builder.cpp +++ b/rts/Sim/Units/UnitTypes/Builder.cpp @@ -30,6 +30,8 @@ #include "System/Log/ILog.h" #include "System/Sound/ISoundChannels.h" +#include "Rendering/Env/NanoParticles/NanoParticleEmitter.h" +#include "Rendering/Env/NanoParticles/NanoParticleSystem.h" #include "System/Misc/TracyDefs.h" using std::min; @@ -350,7 +352,7 @@ bool CBuilder::UpdateBuild(const Command& fCommand) adjBuildSpeed = std::min(repairSpeed, unitDef->maxRepairSpeed * 0.5f - curBuildee->repairAmount); // repair if (adjBuildSpeed > 0.0f && curBuildee->AddBuildPower(this, adjBuildSpeed)) { - CreateNanoParticle(curBuildee->midPos, curBuildee->radius * 0.5f, false); + CreateNanoParticle(curBuildee->midPos, curBuildee->radius * 0.5f, false, false, curBuildee, true); return true; } @@ -516,7 +518,7 @@ bool CBuilder::UpdateCapture(const Command& fCommand) curCapturee->captureProgress += captureProgressStep; curCapturee->captureProgress = std::min(curCapturee->captureProgress, 1.0f); - CreateNanoParticle(curCapturee->midPos, curCapturee->radius * 0.7f, false, true); + CreateNanoParticle(curCapturee->midPos, curCapturee->radius * 0.7f, false, true, curCapturee); if (curCapturee->captureProgress < 1.0f) return true; @@ -976,9 +978,14 @@ void CBuilder::HelpTerraform(CBuilder* unit) } -void CBuilder::CreateNanoParticle(const float3& goal, float radius, bool inverse, bool highPriority) +void CBuilder::CreateNanoParticle(const float3& goal, float radius, bool inverse, bool highPriority, const CUnit* targetUnit, bool fadeWhenTargetComplete) { RECOIL_DETAILED_TRACY_ZONE; + if (NanoParticles::system.Enabled()) { + NanoParticles::emitter.EmitBuilderSpray(this, goal, radius, inverse, highPriority, targetUnit, fadeWhenTargetComplete); + return; + } + const int modelNanoPiece = nanoPieceCache.GetNanoPiece(script); if (!localModel.Initialized() || !localModel.HasPiece(modelNanoPiece)) diff --git a/rts/Sim/Units/UnitTypes/Builder.h b/rts/Sim/Units/UnitTypes/Builder.h index 5e8f7579c43..200fb77227b 100644 --- a/rts/Sim/Units/UnitTypes/Builder.h +++ b/rts/Sim/Units/UnitTypes/Builder.h @@ -51,7 +51,7 @@ class CBuilder : public CUnit bool ScriptStartBuilding(float3 pos, bool silent); void HelpTerraform(CBuilder* unit); - void CreateNanoParticle(const float3& goal, float radius, bool inverse, bool highPriority = false); + void CreateNanoParticle(const float3& goal, float radius, bool inverse, bool highPriority = false, const CUnit* targetUnit = nullptr, bool fadeWhenTargetComplete = false); void SetResurrectTarget(CFeature* feature); void SetCaptureTarget(CUnit* unit); diff --git a/rts/Sim/Units/UnitTypes/Factory.cpp b/rts/Sim/Units/UnitTypes/Factory.cpp index 4d9c4b56346..5b51a680fee 100644 --- a/rts/Sim/Units/UnitTypes/Factory.cpp +++ b/rts/Sim/Units/UnitTypes/Factory.cpp @@ -28,6 +28,8 @@ #include "Game/GlobalUnsynced.h" +#include "Rendering/Env/NanoParticles/NanoParticleEmitter.h" +#include "Rendering/Env/NanoParticles/NanoParticleSystem.h" #include "System/Misc/TracyDefs.h" CR_BIND_DERIVED(CFactory, CBuilding, ) @@ -531,6 +533,11 @@ bool CFactory::ChangeTeam(int newTeam, ChangeType type) void CFactory::CreateNanoParticle(bool highPriority) { RECOIL_DETAILED_TRACY_ZONE; + if (NanoParticles::system.Enabled()) { + NanoParticles::emitter.EmitFactorySpray(this, highPriority); + return; + } + const int modelNanoPiece = nanoPieceCache.GetNanoPiece(script); if (!localModel.Initialized() || !localModel.HasPiece(modelNanoPiece)) diff --git a/rts/System/EventClient.h b/rts/System/EventClient.h index 3b2eaff019c..36061d76252 100644 --- a/rts/System/EventClient.h +++ b/rts/System/EventClient.h @@ -37,6 +37,7 @@ struct FeatureDef; class LuaMaterial; struct WeaponDef; struct SResourcePack; +namespace NanoParticles { struct Event; } #ifndef zipFile // might be defined through zip.h already @@ -285,6 +286,7 @@ class CEventClient virtual void Save(zipFile archive); virtual void Update(); + virtual void NanoParticleUpdate(const std::vector& events) {} virtual void UnsyncedHeightMapUpdate(const SRectangle& rect); virtual void KeyBindingsChanged(); diff --git a/rts/System/EventHandler.cpp b/rts/System/EventHandler.cpp index 570ab1d0bf9..7f8e96eb4ac 100644 --- a/rts/System/EventHandler.cpp +++ b/rts/System/EventHandler.cpp @@ -656,6 +656,12 @@ void CEventHandler::Update() ITERATE_EVENTCLIENTLIST_NA(Update); } +void CEventHandler::NanoParticleUpdate(const std::vector& events) +{ + ZoneScopedN("NanoParticles::LuaUpdate"); + ITERATE_EVENTCLIENTLIST(NanoParticleUpdate, events); +} + void CEventHandler::SunChanged() diff --git a/rts/System/EventHandler.h b/rts/System/EventHandler.h index 688fa0051fc..1525855ef1b 100644 --- a/rts/System/EventHandler.h +++ b/rts/System/EventHandler.h @@ -12,6 +12,7 @@ #include "Sim/Projectiles/Projectile.h" struct CExplosionParams; +namespace NanoParticles { struct Event; } class CWeapon; struct Command; struct BuildInfo; @@ -41,6 +42,8 @@ class CEventHandler bool IsManaged(const std::string& ciName) const; bool IsUnsynced(const std::string& ciName) const; bool IsController(const std::string& ciName) const; + /// Lets the nano particle effect skip building batches nobody listens to. + bool HasNanoParticleUpdateClients() const { return !listNanoParticleUpdate.empty(); } public: @@ -222,6 +225,7 @@ class CEventHandler void UnsyncedHeightMapUpdate(const SRectangle& rect); void Update(); + void NanoParticleUpdate(const std::vector& events); void KeyBindingsChanged(); bool KeyMapChanged(); diff --git a/rts/System/Events.def b/rts/System/Events.def index 118eebd1beb..5add5b1700f 100644 --- a/rts/System/Events.def +++ b/rts/System/Events.def @@ -100,6 +100,7 @@ SETUP_EVENT(UnsyncedHeightMapUpdate, MANAGED_BIT | UNSYNCED_BIT) SETUP_EVENT(Update, MANAGED_BIT | UNSYNCED_BIT) + SETUP_EVENT(NanoParticleUpdate, MANAGED_BIT | UNSYNCED_BIT) SETUP_EVENT(KeyBindingsChanged, MANAGED_BIT | UNSYNCED_BIT) SETUP_EVENT(KeyMapChanged, MANAGED_BIT | UNSYNCED_BIT | CONTROL_BIT) From a88e2608773912ff63a3986cd66a26437b95ac4e Mon Sep 17 00:00:00 2001 From: Rysica <309933144+Rysicaa@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:31:06 -0700 Subject: [PATCH 2/2] Fix resistance calculations to use maximumSpeed after applying terrain modifiers (#3336) * apply terrain speed mod to max speed for drag calculations --- doc/pr-changelogs/3336.md | 1 + rts/Sim/MoveTypes/GroundMoveType.cpp | 8 ++++++-- rts/Sim/MoveTypes/GroundMoveType.h | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 doc/pr-changelogs/3336.md diff --git a/doc/pr-changelogs/3336.md b/doc/pr-changelogs/3336.md new file mode 100644 index 00000000000..709868d8b8e --- /dev/null +++ b/doc/pr-changelogs/3336.md @@ -0,0 +1 @@ + * fixed terrain speed bonuses (e.g. typemap ice) being cancelled by overspeed drag that used the unit's base max speed instead of the terrain-modified effective max speed. diff --git a/rts/Sim/MoveTypes/GroundMoveType.cpp b/rts/Sim/MoveTypes/GroundMoveType.cpp index c7e97eceef9..3b5b016d043 100644 --- a/rts/Sim/MoveTypes/GroundMoveType.cpp +++ b/rts/Sim/MoveTypes/GroundMoveType.cpp @@ -131,6 +131,7 @@ CR_REG_METADATA(CGroundMoveType, ( CR_MEMBER(wantedSpeed), CR_MEMBER(currentSpeed), CR_MEMBER(deltaSpeed), + CR_MEMBER(terrainSpeedMod), CR_MEMBER(currWayPointDist), CR_MEMBER(prevWayPointDist), @@ -449,11 +450,12 @@ static float3 CalcSpeedVectorExclGravity(const CUnit* owner, const CGroundMoveTy else { float vel = owner->speed.w; float maxSpeed = owner->moveType->GetMaxSpeed(); - if (vel > maxSpeed) { + const float effectiveMaxSpeed = maxSpeed * mt->GetTerrainSpeedMod(); + if (vel > effectiveMaxSpeed) { // Once a unit is travelling faster than their maximum speed, their engine power is no longer sufficient to counteract // the drag from air and rolling resistance. So reduce their velocity by these forces until a return to maximum speed. float rollingResistanceCoeff = owner->unitDef->rollingResistanceCoefficient; - vel = std::max(maxSpeed, + vel = std::max(effectiveMaxSpeed, (owner->speed + owner->GetDragAccelerationVec( mapInfo->atmosphere.fluidDensity, @@ -1316,6 +1318,8 @@ void CGroundMoveType::ChangeSpeed(float newWantedSpeed, bool wantReverse, bool f if (groundSpeedMod == 0.0f) groundSpeedMod = CMoveMath::GetPosSpeedMod(*md, owner->pos + flatFrontDir * SQUARE_SIZE, flatFrontDir); + terrainSpeedMod = groundSpeedMod; + const float curGoalDistSq = (owner->pos - goalPos).SqLength2D(); const float minGoalDistSq = Square(BrakingDistance(currentSpeed, decRate)); diff --git a/rts/Sim/MoveTypes/GroundMoveType.h b/rts/Sim/MoveTypes/GroundMoveType.h index 3f10be4e063..bc4285c6e95 100644 --- a/rts/Sim/MoveTypes/GroundMoveType.h +++ b/rts/Sim/MoveTypes/GroundMoveType.h @@ -113,6 +113,7 @@ class CGroundMoveType : public AMoveType float GetWantedSpeed() const { return wantedSpeed; } float GetCurrentSpeed() const { return currentSpeed; } float GetDeltaSpeed() const { return deltaSpeed; } + float GetTerrainSpeedMod() const { return terrainSpeedMod; } float GetCurrWayPointDist() const { return currWayPointDist; } float GetPrevWayPointDist() const { return prevWayPointDist; } @@ -253,6 +254,7 @@ class CGroundMoveType : public AMoveType float wantedSpeed = 0.0f; float currentSpeed = 0.0f; float deltaSpeed = 0.0f; + float terrainSpeedMod = 1.0f; /// last groundSpeedMod from ChangeSpeed (typemap × slope/depth) float currWayPointDist = 0.0f; float prevWayPointDist = 0.0f;