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/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/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/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; 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)