diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl new file mode 100644 index 0000000000..7a791623fd --- /dev/null +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -0,0 +1,518 @@ +#version 430 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require +#extension GL_ARB_shader_storage_buffer_object : require + +uniform sampler2D infoTex; +uniform float gameTime; +uniform float bakeTime; +uniform float enableFlow; // 1.0 = full bubble pass; 0.0 = static cables (no animation) +uniform float ghostsEnabled; // 1.0 = ghost branch active; 0.0 = enemy OOL discards immediately + +// Same SSBO as the GS — per-edge coverage bitmask, gates per-segment +// ghost rendering for enemy fragments. +layout (std430, binding = 6) coherent buffer cableCoverageBuffer { + uvec4 cableCoverage[]; +}; + +in DataGS { + vec3 worldPos; + float capacity; + float isBranch; + float width; + vec2 cableUV; + vec2 timeData; + vec4 gridData; + float spawnAlongMain; // overloaded: main ribbon → lenPerSeg; twig → twig-along + flat int gsSlot; +}; + +//__ENGINEUNIFORMBUFFERDEFS__ + +// ===================================================================== +// VISUAL TUNING — knobs you most likely want to tweak when devving. +// Pure aesthetic constants; nothing here changes geometry or topology. +// ===================================================================== + +// Grow/wither animation rates (elmos/s) — must match unsynced GROWTH_RATE/ +// WITHER_RATE so the CPU-side bubble phase anchor and the FS-side growth +// front sweep at the same speed. +const float GROWTH_RATE = 250.0; +const float WITHER_RATE = 400.0; + +// Bark / inner colours. Bark = visible outer cable; inner = brighter core +// shown through the centre line by `innerMix`. capT (capacity / 100) only +// blends `innerColor` between two grey levels; no hue. +const vec3 BARK_COLOR = vec3(0.55); +const vec3 INNER_COLOR_LO = vec3(0.65); // capT = 0 +const vec3 INNER_COLOR_HI = vec3(0.85); // capT = 1 +const float TWIG_INNER_DAMPEN = 0.7; // twigs read more uniformly than trunks + +// Lighting: floor on diffuse keeps fully-shaded sides from going pitch black +// (cables read as plasma conduits, not asphalt); spec is blinn-phong on a +// synthetic cylinder normal. +const float DIFFUSE_FLOOR = 0.25; +const float SPEC_EXP = 24.0; +const float SPEC_MAGNITUDE = 0.35; +const vec3 SPEC_TINT = vec3(1.0, 0.95, 0.85); + +// LOS / ghost: dim factor remaps losState through this range; fullLOS uses +// a hard threshold so bubbles only animate inside actual visibility. +const float DIM_LOS_LO = 0.3; +const float DIM_LOS_HI = 0.8; +const float DIM_FACTOR_MIN = 0.3; // bark brightness at full darkness +const float FULLLOS_LO = 0.7; +const float FULLLOS_HI = 1.0; + +// Enemy LOS gating: below this losState, enemy fragments are hidden entirely +// (no ghost). Own-ally fragments ignore this threshold — they fade via +// dimFactor instead but always render. +const float ENEMY_LOS_CUT = 0.5; + +// Bubble flow mapping. Must mirror Lua flowToSpeed() exactly for CPU-baked +// phase anchoring + FS extrapolation to remain continuous across baking. +const float MAX_SPEED = 110.0; +const float FLOW_REF = 50.0; +const float MIN_TRUNK_W = 3.0; +const float SPACING_A = 105.0; // big bubble layer +const float SPACING_B = 48.0; // small bubble layer +const float BUBBLE_BIG_R = 7.5; +const float BUBBLE_SMALL_R = 4.0; + +// Bubble compositing weights. +const float HALO_WEIGHT = 0.70; +const float BODY_WEIGHT = 1.85; +const float SPEC_WEIGHT = 1.10; +const float GRID_DESAT = 0.18; // how much to mute saturated grid hue +const float BUBBLE_WHITE_MIX = 0.15; // mix into pure white for "hot core" +const float HALO_WEIGHT_LAYER = 0.55; // layer-B halo blend + +// Twig pulse: a fast wave sweeps along the cable's `along` axis (used to +// pick which twig fires next, encoding direction-from-root). When the wave +// passes a twig's root, a slow sub-wave sweeps the twig itself. +const float CABLE_PROP_SPEED = 400.0; // elmos/s — fast inter-twig stagger +const float CABLE_PROP_PERIOD = 2800.0; // elmos → 7s recurrence at 400/s +const float TWIG_SWEEP_SPEED = 90.0; // elmos/s — visible motion within a twig +const float PULSE_HW = 5.0; // Gaussian sigma in elmos +const float PULSE_INTENSITY = 0.55; +const float PULSE_BODY_W = 1.10; +const float PULSE_SPEC_W = 0.55; +const float PULSE_HALO_W = 0.50; + +out vec4 fragColor; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +float hash1(float n) { + return fract(sin(n * 12.9898) * 43758.5453); +} + +// One layer of advecting bubbles drawn as world-space-round glassy spheroids. +// Density is fixed per layer (`spacing` constant); only `speed` changes with +// flow. Each bubble has hash-derived size + cross-axis offset jitter so the +// cable looks like bubbly fluid rather than a metronome. +// +// Crucially, distance is measured in actual world-space elmos in BOTH axes +// (along + cross), so bubbles are real circles regardless of cable thickness. +// `halfWidthE` is the cable cross half-extent in elmos at this fragment +// (= width * 0.5); `radiusE` is each bubble's target radius in elmos and is +// clamped so big bubbles fit inside thin cables instead of clipping to a +// stripe. +// +// Shading: faint inner glow + Fresnel rim + small offset highlight, all with +// smoothstep edges to avoid pixelation at oblique camera angles. Returns +// (body, specular). +// `phase` is the integrated travel distance baked + extrapolated by the +// caller (CPU integrates ∫ speed dt, shader extrapolates the last segment +// with the current speed). Subtracting from `along` advects bubbles smoothly +// across speed changes. +// +// Returns vec3: (body, specular, halo). Caller composites all three with +// possibly different colour weights for richer look. +vec3 bubbleLayer(float along, float phase, float spacing, + float radiusMax, float v, float halfWidthE, float layerSeed) { + float along2 = along - phase; + float idxLow = floor(along2 / spacing); + float coord = along2 - idxLow * spacing; // [0, spacing) + float idxNear = (coord < spacing * 0.5) ? idxLow : (idxLow + 1.0); + float dAlong = (coord < spacing * 0.5) ? coord : (spacing - coord); + + float h1 = hash1(idxNear + layerSeed); + float h2 = hash1(idxNear + layerSeed + 71.3); + // Bubble radius in elmos. Random per bubble; clamped so it sits within + // the cable cross-section even on thin twigs. + float radiusE = radiusMax * (0.7 + 0.3 * h1); + radiusE = min(radiusE, halfWidthE * 0.97); + if (radiusE < 0.5) return vec3(0.0); + + // Cross-axis offset: in elmos, only as much margin as the cable can + // afford. Skinny cables → bubble centred; chunky cables → bubble can + // drift a little off-axis. + float crossMargin = max(0.0, halfWidthE - radiusE); + float yOffsetE = (h2 - 0.5) * crossMargin * 1.0; + + float dCrossE = v * halfWidthE - yOffsetE; + // Use the wider "halo radius" for the early-exit so the halo, which + // extends past r=1, isn't truncated. + float haloR = radiusE * 1.5; + float r2H = (dAlong * dAlong + dCrossE * dCrossE) / (haloR * haloR); + if (r2H >= 1.0) return vec3(0.0); + + float r2 = (dAlong * dAlong + dCrossE * dCrossE) / (radiusE * radiusE); + float r = sqrt(r2); + float xn = dAlong / radiusE; + float yn = dCrossE / radiusE; + + // Screen-space derivative AA. Keeps every smoothstep edge ~1 pixel wide + // regardless of zoom; fixes thick-cable staircase pixelation. + float aa = clamp(fwidth(r) * 1.4, 0.005, 0.20); + + // HOT CORE — Gaussian-style bright nucleus, peaks at r=0. Reads as + // glowing plasma rather than a flat disc. + float core = exp(-r2 * 4.5); + core *= 1.0 - smoothstep(1.0 - aa, 1.0, r); + + // SHARP RIM — thin meniscus highlight near r ≈ 0.85. + float rim = smoothstep(0.55 - aa, 0.85, r) + * (1.0 - smoothstep(0.85, 1.0 - aa * 0.4, r)); + rim *= 1.4; + + // SPECULAR — small bright dot offset toward the light direction. + vec2 hd = vec2(xn + 0.32, yn + 0.42); + float hr = length(hd); + float spec = 1.0 - smoothstep(0.0, 0.22 + aa, hr); + spec *= spec * spec; // cubed → very sharp + + // HALO — soft additive bloom outside the bubble's hard edge. Extends + // from r=0 out to r=1.5 with a gentle Gaussian falloff. + float halo = exp(-r2 * 0.9) * 0.45; + + return vec3(core + rim, spec, halo); +} + +// HSL → RGB at S=1, L=0.5 — matches LuaUI/Headers/overdrive.lua's GetGridColor +// (hue is the same triangle wave used for the panel/grid colour). Hue in [0,1). +vec3 hueToRgb(float h) { + h = fract(h); + float r = clamp(abs(h * 6.0 - 3.0) - 1.0, 0.0, 1.0); + float g = clamp(2.0 - abs(h * 6.0 - 2.0), 0.0, 1.0); + float b = clamp(2.0 - abs(h * 6.0 - 4.0), 0.0, 1.0); + return vec3(r, g, b); +} + +// efficiency (energy/metal ratio) → bubble colour, matching the economy +// panel's grid swatch (LuaUI/Headers/overdrive.lua). The Lua side computes +// `h = 5760 / (eff+2)^2` (clamped at eff < 3.5 to h = 190) and then feeds +// `h / 255` into HSLtoRGB — so the hue divisor here is 255, not 360. +// Result: low-load grids are blue/teal, fully-saturated grids go yellow→red. +vec3 gridEfficiencyColor(float eff) { + if (eff <= 0.0) return vec3(1.0, 0.25, 1.0); + float h; + if (eff < 3.5) { + h = 190.0; + } else { + h = 5760.0 / ((eff + 2.0) * (eff + 2.0)); + } + return hueToRgb(h / 255.0); +} + +// Ghost shading: simple flat light-gray, alpha-blended over terrain. +// No lighting, no shimmer, no cylinder normal — reads as a memory trace. +const vec3 GHOST_COLOR = vec3(0.72); // light neutral gray +const float GHOST_CAP_TINT = 0.18; // small capacity-driven brighten +const float GHOST_BRANCH_DAMP = 0.85; +const float GHOST_ALPHA_BASE = 0.45; // translucent baseline +const float GHOST_EDGE_FADE_LO = 0.55; +const float GHOST_EDGE_FADE_HI = 0.90; + +void main() { + float v = cableUV.y; + float t = abs(v); + if (t > 0.90) discard; + + // Visual grow/wither: cableUV.x is distance along cable in elmos. + // Growth front advances from u=0 forward. + float along = cableUV.x; + float visibleFront = (gameTime - timeData.x) * GROWTH_RATE; + if (along > visibleFront) discard; + // Wither: tail eats forward from u=0 (witherTime > 0 means withering). + if (timeData.y > 0.5) { + float witherFront = (gameTime - timeData.y) * WITHER_RATE; + if (along < witherFront) discard; + } + + // FAST GHOST PATH — orphaned-enemy edges (gridData.w = -1.0) skip all the + // cylinder-normal / lighting / bubble math below. Read LOS + coverage, + // decide, render translucent flat ghost or discard. Nothing else. + if (gridData.w < -0.5) { + vec2 losUV0 = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; + float los0 = texture(infoTex, losUV0).r; + if (los0 >= ENEMY_LOS_CUT) discard; + uint cov0 = (gsSlot >= 0) ? cableCoverage[gsSlot].x : 0u; + // Per-segment ghost gating, mirroring the calc done lower in the FS + // for live fragments. spawnAlongMain carries lenPerSeg for main + // ribbons, twigs use any-bit fallback. + uint segBit0; + if (isBranch < 0.5) { + float lenPerSeg0 = spawnAlongMain; + int segIdx0 = (lenPerSeg0 > 0.0) ? clamp(int(cableUV.x / lenPerSeg0), 0, 23) : 0; + segBit0 = 1u << uint(segIdx0); + } else { + segBit0 = 0xFFFFFFu; + } + if ((cov0 & segBit0) == 0u) discard; + + // Flat ghost shade: light gray, slight capacity-driven brightening, + // branches a touch dimmer. Alpha-blend over terrain (depth-write off + // in the ghost draw pass) so the ribbon is a translucent overlay + // rather than an opaque painted line. + float capT0 = clamp(capacity / 100.0, 0.0, 1.0); + vec3 ghost0 = GHOST_COLOR * (1.0 - GHOST_CAP_TINT + GHOST_CAP_TINT * 2.0 * capT0); + if (isBranch > 0.5) ghost0 *= GHOST_BRANCH_DAMP; + float edgeFade0 = 1.0 - smoothstep(GHOST_EDGE_FADE_LO, GHOST_EDGE_FADE_HI, t); + fragColor = vec4(ghost0, GHOST_ALPHA_BASE * edgeFade0); + return; + } + + // Cylinder cross-section normal that respects cable slope, derived from + // the smoothly-interpolated cable tangent passed in by the GS. + // + // `vsTangent` is set per-vertex to the local cable along-direction (back- + // diff of adjacent centerline vertices). The triangle-strip rasteriser + // linearly interpolates it across triangles → adjacent fragments along the + // cable see a continuously rotating tangent, so the cylinder's lit side + // bends smoothly with up/down hills instead of stepping per triangle (as + // happens when the basis is reconstructed from `dFdx(worldPos)`, which is + // flat per triangle). + // + // Cross-section axis is `cross(worldUp, cableT)` — purely horizontal, which + // matches the GS's global B3 (≈ cross(Navg, T_g)) closely enough for any + // terrain whose Navg is near +Y. Sign matches: GS emits leftPos at -B3 + // (cableUV.y = -1), rightPos at +B3 (cableUV.y = +1), and `cross(Y, T)` + // gives the same direction as cross(Navg, T) up to a small Y component. + // Reconstruct cable tangent from screen-space derivatives of (worldPos, cableUV.x). + // This is per-triangle flat (cableUV.x is linearly interpolated, so derivatives + // are constant within a triangle), but cheaper than passing a vec3 varying. + vec3 dWdx_loc = dFdx(worldPos); + vec3 dWdy_loc = dFdy(worldPos); + float duDx = dFdx(cableUV.x); + float duDy = dFdy(cableUV.x); + float duDenom = duDx * duDx + duDy * duDy; + vec3 cableT = (duDenom > 1e-6) + ? normalize((dWdx_loc * duDx + dWdy_loc * duDy) / duDenom) + : vec3(1.0, 0.0, 0.0); + vec3 perp3D = cross(vec3(0.0, 1.0, 0.0), cableT); + float perp3DL = length(perp3D); + if (perp3DL > 1e-3) { + perp3D /= perp3DL; + } else { + // Cable nearly vertical — pick an arbitrary horizontal perp. + perp3D = vec3(1.0, 0.0, 0.0); + } + + vec3 trueUp = cross(cableT, perp3D); + if (trueUp.y < 0.0) trueUp = -trueUp; // ensure pointing skyward + trueUp = normalize(trueUp); + + float up = sqrt(max(0.0, 1.0 - v * v)); + vec3 cylNormal = normalize(trueUp * up + perp3D * v); + + // Own lighting (forward rendered, no engine lighting applies) + float diffuse = max(DIFFUSE_FLOOR, dot(cylNormal, normalize(sunDir.xyz))); + + // Specular + vec3 viewDir = normalize(cameraViewInv[3].xyz - worldPos); + vec3 halfDir = normalize(normalize(sunDir.xyz) + viewDir); + float spec = pow(max(0.0, dot(cylNormal, halfDir)), SPEC_EXP) * SPEC_MAGNITUDE; + + // Bark / inner gray-scale tint by capacity. Industrial conduit look. + float capT = clamp(capacity / 100.0, 0.0, 1.0); + vec3 innerColor = mix(INNER_COLOR_LO, INNER_COLOR_HI, capT); + + float innerMix = smoothstep(0.85, 0.15, t); + if (isBranch > 0.5) innerMix *= TWIG_INNER_DAMPEN; + vec3 baseColor = mix(BARK_COLOR, innerColor, innerMix); + + // Surface noise detail + float surfN = hash(worldPos.xz * 0.5) * 0.04; + baseColor += vec3(surfN); + + // LOS state — sampled from $info:los (single-channel red), the engine's + // actual game-logic LOS texture. Independent of the user's overlay toggle: + // 0.0 = unscouted, 1.0 = currently in LOS. + vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; + float losState = texture(infoTex, losUV).r; + float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); + + // Coverage bits are written by the GS (per-segment, per cable per frame). + // Per-fragment gating: derive segIdx from along-distance + len-per-segment + // packed into spawnAlongMain (see DataGS comment). Twigs use bit 0 as a + // fallback — they're decorative and only show when the parent has any + // coverage anyway. + uint segBit; + if (isBranch < 0.5) { + float lenPerSeg = spawnAlongMain; + int segIdx = (lenPerSeg > 0.0) ? clamp(int(cableUV.x / lenPerSeg), 0, 23) : 0; + segBit = 1u << uint(segIdx); + } else { + segBit = 0xFFFFFFu; // twig: any-bit-set OK + } + + // Three render classes for the FS: + // isOwnAlly = 1.0 → own ally, always live (existing path below). + // isOwnAlly = 0.0 → live enemy edge: render live in LOS, ghost in fog + // (gated by segment bit), discard if never seen. + // isOwnAlly = -1.0 → orphaned ghost (synced removed it; we kept a + // snapshot). Always render ghost gated by segment + // bit; never live, regardless of LOS. This is the + // "you don't know it died" persistence. + float isOwnAlly = gridData.w; + bool isGhostEdge = isOwnAlly < -0.5; + + // Re-scout clear is handled by the GS (atomicAnd at segment midpoints + // when the ghost edge's bits overlap with current LOS). Here in the FS + // we just discard the ghost fragment when it's in current LOS — the + // player is looking at empty ground, the cable shouldn't show. + if (isGhostEdge && losState >= ENEMY_LOS_CUT) discard; + + bool enemyOutOfLOS = (isOwnAlly < 0.5 && isOwnAlly > -0.5 && losState < ENEMY_LOS_CUT); + if (enemyOutOfLOS) { + // Ghosts disabled: no SSBO read, no branch evaluation; just discard. + if (ghostsEnabled < 0.5) discard; + uint cov = (gsSlot >= 0) ? cableCoverage[gsSlot].x : 0u; + if ((cov & segBit) == 0u) discard; + // Same flat translucent shading as the ghost-VBO fast path so + // live-out-of-LOS and orphaned ghosts read identically. + float capT2 = clamp(capacity / 100.0, 0.0, 1.0); + vec3 ghost = GHOST_COLOR * (1.0 - GHOST_CAP_TINT + GHOST_CAP_TINT * 2.0 * capT2); + if (isBranch > 0.5) ghost *= GHOST_BRANCH_DAMP; + float edgeFade = 1.0 - smoothstep(GHOST_EDGE_FADE_LO, GHOST_EDGE_FADE_HI, t); + fragColor = vec4(ghost, GHOST_ALPHA_BASE * edgeFade); + return; + } + + // Apply lighting + vec3 color = baseColor * diffuse + SPEC_TINT * spec; + + // Static-cable detail level: skip the entire bubble pass and bark dim. + // `enableFlow` is a uniform driven by the synced /cabletree flow toggle, + // so the same draw call cheaply shortcuts to a flat-lit cable when the + // player has opted out of the animated visual. + if (enableFlow < 0.5) { + // Still apply LOS-aware bark dim so out-of-LOS cables read as + // shadowed; just don't add bubble glow on top. + color *= mix(DIM_FACTOR_MIN, 1.0, smoothstep(DIM_LOS_LO, DIM_LOS_HI, losState)); + fragColor = vec4(color, 1.0); + return; + } + + // Energy bubbles travelling along the cable, like fluid in a pipe. + // + // Design: + // - +u is the direction of energy flow (synced reorients edges by + // current flow); all cables share one global phase so we never get + // the optical illusion of "counter motion" inside a single cable. + // - Density (bubbles per elmo) is FIXED: every cable shows the same + // bubbly look regardless of how loaded it is. What changes with + // flow is the SPEED bubbles travel at — zero flow leaves them + // motionless; high flow makes them zip. + // - Two layered streams of bubbles (big + small) with random per-bubble + // size + cross-axis offset, so the cable looks like a real bubbly + // slurry instead of a metronome of identical dots. + // Bubble speed/density mapping. MUST match the CPU's flowToSpeed for the + // integrated phase anchoring to stay consistent. + // + // Cable thickness conveys capacity (orthogonal); flow is encoded by speed + // and density together. Each scales as sqrt(flow/FLOW_REF) and ramps + // monotonically, so they read as one fused "more lively" signal. Their + // product = (sqrt(...))² is linear in flow, matching actual throughput. + float flow = gridData.y; + // Linear thickness divisor: a cable 4× thicker than min gets its flow + // signal scaled to 1/4 before the sqrt → ~0.5× visual liveliness. Slight + // negative bias for thick cables, matching the CPU's flowToSpeed. + float thicknessRatio = max(1.0, width / MIN_TRUNK_W); + float effFlow = max(flow, 0.0) / thicknessRatio; + float n = sqrt(effFlow / FLOW_REF); + float speed = MAX_SPEED * n; + + float halfWidthE = width * 0.5; // cable cross half-extent in elmos + + // Phase = CPU's baked phase (snapshot at bakeTime) + linear extrapolation + // at the current speed. Speed *changes* update the rate of advance from + // here — bubbles don't teleport. + float phase = gridData.z + speed * (gameTime - bakeTime); + + // Density: spacing inversely scales with the same sqrt factor, floored at + // `n=0.3` so a near-zero-flow cable still shows widely-spaced bubbles + // rather than nothing or overlapping spam. + float spacingMul = max(0.3, n); + float spacingA = SPACING_A / spacingMul; + float spacingB = SPACING_B / spacingMul; + + // Bubble pass: main ribbon uses two advecting bubble layers; twigs do a + // two-stage wave (see CABLE_PROP_SPEED + TWIG_SWEEP_SPEED). + float bubbleBody, bubbleSpec, bubbleHalo; + if (isBranch > 0.5) { + // Two-stage wavefront (decoupled cable-stagger + twig-sweep): + // 1. CABLE_PROP_SPEED sweeps a virtual fast wave along the cable's + // `along` axis. Twigs at lower spawnAlongMain get hit earlier, + // so the stagger encodes direction-from-root. + // 2. When that wave passes a twig's root, a slower sub-wave starts + // at twig-local 0 and propagates through the twig at + // TWIG_SWEEP_SPEED. Inter-twig stagger feels snappy while motion + // *within* a twig stays comfortable. + // `spawnAlongMain` is what lets us decouple these — without it both + // speeds would be tied to the same propagation rate. + float wavePassedElmos = mod(gameTime * CABLE_PROP_SPEED - spawnAlongMain, CABLE_PROP_PERIOD); + float subwavePos = TWIG_SWEEP_SPEED * (wavePassedElmos / CABLE_PROP_SPEED); + float localAlong = along - spawnAlongMain; + float d = localAlong - subwavePos; + // No wrap correction: when subwavePos overshoots the twig the + // Gaussian naturally falls to ~0 for any fragment. + float pulse = exp(-(d * d) / (PULSE_HW * PULSE_HW)); + float crossT = 1.0 - smoothstep(0.7, 1.0, v * v); + float intensity = pulse * crossT * PULSE_INTENSITY; + bubbleBody = intensity * PULSE_BODY_W; + bubbleSpec = intensity * PULSE_SPEC_W; + bubbleHalo = intensity * PULSE_HALO_W; + } else { + vec3 bA = bubbleLayer(along, phase, spacingA, BUBBLE_BIG_R, v, halfWidthE, 3.7); + vec3 bB = bubbleLayer(along, phase, spacingB, BUBBLE_SMALL_R, v, halfWidthE, 19.1); + bubbleBody = bA.x + bB.x * 0.85; + bubbleSpec = bA.y + bB.y * 0.85; + bubbleHalo = bA.z + bB.z * HALO_WEIGHT_LAYER; + } + + // Bubble colour: grid-efficiency hue, lightly toned down so it still + // glows clearly but isn't neon-saturated. + vec3 gridColor = gridEfficiencyColor(gridData.x); + float gridLum = dot(gridColor, vec3(0.299, 0.587, 0.114)); + vec3 grayedGrid = mix(gridColor, vec3(gridLum), GRID_DESAT); + vec3 bubbleColor = mix(grayedGrid, vec3(1.0), BUBBLE_WHITE_MIX); + vec3 haloColor = grayedGrid; + + // LOS-aware dimming on the BARK ONLY. Bubbles are plasma — emissive, so + // they shouldn't fade in shadow. Composing them after the dim means + // glowing balls remain "lights in the dark" rather than disappearing in + // LOS-dim regions. + float dimFactor = mix(DIM_FACTOR_MIN, 1.0, smoothstep(DIM_LOS_LO, DIM_LOS_HI, losState)); + color *= dimFactor; + + // Composition order: + // - Halo: additive (soft underglow that should mix with bark colour). + // - Body: max() over current colour, so dark bark can't leak into the + // bubble's true grid hue. Plain additive composition causes hue + // shifts (orange → yellow, magenta → pink) because the bark's green + // channel piles onto the emissive. max() lets the emissive plasma + // show its real colour through the cable in shadow. + // - Spec: additive white sparkle on top. + color += haloColor * bubbleHalo * fullLOS * HALO_WEIGHT; + vec3 bubbleEmissive = bubbleColor * bubbleBody * fullLOS * BODY_WEIGHT; + color = max(color, bubbleEmissive); + color += vec3(1.0) * bubbleSpec * fullLOS * SPEC_WEIGHT; + + // FULLY OPAQUE output — like lava. No alpha blending. + fragColor = vec4(color, 1.0); +} diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl new file mode 100644 index 0000000000..a352a6febe --- /dev/null +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -0,0 +1,585 @@ +#version 430 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require +#extension GL_ARB_gpu_shader5 : require +#extension GL_ARB_shader_storage_buffer_object : require + +// Full GS: takes one GL_LINES primitive (cable endpoints) and emits the cable +// ribbon. Uses GS invocations: each invocation runs main() with its own +// max_vertices budget, so we can: +// invocation 0 → main wiggly ribbon (SEGMENTS+1 boundaries × 2 verts) +// invocations 1..N-1 → one twig each (4 verts), conditional on a hash +// This sidesteps the per-program max_vertices limit and keeps the FS body +// unchanged. + +layout (lines, invocations = 5) in; +// 50 verts/invocation comfortably fits min-spec total components budget; +// invocation 0 uses ~50, twig invocations use 4. +layout (triangle_strip, max_vertices = 50) out; + +uniform sampler2D heightmapTex; +uniform sampler2D infoTex; // $info:los; same texture FS samples +uniform float ghostsEnabled; // 1.0 = run coverage SSBO updates, 0.0 = bypass entirely + +// Per-edge "have I been seen" bitmask. Bit `i` of `.x` is set when segment +// `i` of the cable in slot s has been in LOS at any point. Persistent across +// frames. Live edges atomicOr bits in; ghost edges atomicAnd them off when +// the player re-scouts the area (re-scout-clear pass). +// +// Slots are declared as uvec4 because Spring's VBO API requires vec4-aligned +// attributes; we only use `.x` and ignore `.yzw`. +layout (std430, binding = 6) coherent buffer cableCoverageBuffer { + uvec4 cableCoverage[]; +}; + +in DataVS { + vec2 vsWorldXZ; + vec3 vsCableData; + vec4 vsGridData; + flat int vsSlot; +} dataIn[]; + +// Block must match `in DataGS` in gfx_overdrive_cables.frag.glsl exactly. +// PACKING NOTE: `spawnAlongMain` is reused. For twig fragments (isBranch>0.5) +// it carries the twig's root-along distance (existing semantics, drives twig +// pulse animation). For main-ribbon fragments (isBranch<0.5) it carries the +// cable's len-per-segment so the FS can derive a per-segment bit index for +// the coverage SSBO. We pack rather than add a varying because adding one +// more component pushes 50 × 21 = 1050 over GL_MAX_GEOMETRY_OUTPUT_COMPONENTS +// (1024 min-spec); the two semantics are disjoint by isBranch so no conflict. +out DataGS { + vec3 worldPos; + float capacity; + float isBranch; + float width; + vec2 cableUV; + vec2 timeData; + vec4 gridData; + float spawnAlongMain; + flat int gsSlot; +}; + +//__ENGINEUNIFORMBUFFERDEFS__ + +vec2 inverseMapSize = 1.0 / mapSize.xy; + +float heightAtWorldPos(vec2 w) { + const vec2 heightmaptexel = vec2(8.0, 8.0); + w += vec2(-8.0, -8.0) * (w * inverseMapSize) + vec2(4.0, 4.0); + vec2 uvhm = clamp(w, heightmaptexel, mapSize.xy - heightmaptexel); + uvhm = uvhm * inverseMapSize; + return textureLod(heightmapTex, uvhm, 0.0).x; +} + +// Terrain normal at a world XZ point via 4-tap finite-difference of the +// heightmap. Cheap (4 fetches) and good enough for placing twigs into the +// slope's local tangent plane. +vec3 terrainNormal(vec2 xz) { + const float E = 8.0; + float hxR = heightAtWorldPos(xz + vec2( E, 0.0)); + float hxL = heightAtWorldPos(xz + vec2(-E, 0.0)); + float hzU = heightAtWorldPos(xz + vec2(0.0, E)); + float hzD = heightAtWorldPos(xz + vec2(0.0, -E)); + return normalize(vec3(hxL - hxR, 2.0 * E, hzD - hzU)); +} + +// Mirror of Lua-side Hash() / NoisyPath() so cables look exactly like before. +float gsHash(float x, float z, float seed) { + return fract(sin(x * 12.9898 + z * 78.233 + seed * 43.17) * 43758.5453) * 2.0 - 1.0; +} +float gsHashU(float x, float z, float seed) { // [0,1] variant + return (gsHash(x, z, seed) + 1.0) * 0.5; +} +float gsNoiseScale(float t) { + if (t < 0.1) return t / 0.1; + if (t > 0.9) return (1.0 - t) / 0.1; + return 1.0; +} + +const int MAX_SEGMENTS = 24; // hardware budget (max_vertices=50 → 25 boundaries × 2). Cable lengths are bounded by pylon range so this isn't expected to clamp in practice. +const float SEG_LEN_TARGET = 22.0; // elmos of 3D arc per segment +const float NOISE_AMP_ABS = 4.0; +const float WIDTH_FACTOR = 0.55; +const float MIN_TRUNK_WIDTH = 3.0; +const float MAX_TRUNK_WIDTH = 12.0; +const float MAX_CAPACITY_REF = 225.0; // one singu (energysingu.energyMake) + +// Twig parameters mirror the Lua-side BRANCH_* constants. +const float BRANCH_CHANCE = 0.78; +const float BRANCH_LEN_MIN = 15.0; +const float BRANCH_LEN_MAX = 50.0; +const float BRANCH_ANGLE_MIN = 0.4; +const float BRANCH_ANGLE_MAX = 1.1; +const float BRANCH_WIDTH = 0.85; + +// Vertical clearance over the heightmap. CENTERLINE_CLEAR is added on top of +// the max-of-window lift, so it doesn't need a big pad. TWIG_CLEAR is set +// 0.6 elmos below CENTERLINE_CLEAR so the twig sits just under the trunk's +// centerline at the junction (avoids z-fighting while staying visually +// attached). SIDE_CLEAR catches concave cross-slopes where the slope-tangent +// offset would otherwise place the side vertex below local terrain. +const float CENTERLINE_CLEAR = 1.5; +const float TWIG_CLEAR = 0.9; +const float SIDE_CLEAR = 0.8; + +float gOutBranch = 0.0; +// gOutSpawnAlong is overloaded (see DataGS comment): for main-ribbon emits +// (gOutBranch=0) it carries len-per-segment; for twig emits (gOutBranch=1) +// it carries the twig's root-along distance. +float gOutSpawnAlong = 0.0; +int gOutSlot = -1; + +void emitVtx(vec3 wp, vec3 tangent3D, vec2 cuv, + float w, vec4 grid, vec2 td, float cap) { + worldPos = wp; + capacity = cap; + isBranch = gOutBranch; + width = w; + cableUV = cuv; + timeData = td; + gridData = grid; + spawnAlongMain = gOutSpawnAlong; + gsSlot = gOutSlot; + // (vsTangent varying disabled — exceeded GS output budget on this hardware) + gl_Position = cameraViewProj * vec4(wp, 1.0); + EmitVertex(); +} + +// Arc-bias parameters: at each point along the cable, probe the heightmap +// sideways and pull the centerline toward the lower-elevation side. The +// per-point lateral budget shrinks tent-style toward the endpoints, so the +// path is anchored at the pylons and free in the middle — worst case the +// whole cable forms a smooth arc. Adds *on top of* the existing high-frequency +// wiggle (which gives bark/seam variation), so the result is "arched chord +// with bark wiggle" rather than either alone. +const float ARC_PROBE_DIST = 35.0; // elmos to each side for the slope probe +const float ARC_MAX_DEV_FRAC = 0.18; // midpoint cap = ARC_MAX_DEV_FRAC * lenAB +const float ARC_DH_SAT = 6.0; // probe Δheight (elmos) at which pull saturates to maxDev +const float ARC_MIN_LEN = 80.0; // shorter cables: skip arc bias entirely + +// Computes ONE cable-global pull direction by averaging dh probes at 5 +// anchor points along the chord. Computed once per cable in main() and +// reused for all segments and twigs. +// +// Why averaging instead of per-t probing: +// Probing dh at *each* segment t evaluates a fresh terrain feature at the +// chord position, so the pull direction can flip between adjacent segments +// — the cable then 90°-zigzags through the terrain. A single global dh +// (signed mean across the chord) produces a monotonic arc: the whole cable +// bends in one direction, magnitude shaped by the tent envelope. Micro +// wiggles still come from the existing high-frequency noise pass, so the +// "still perturbed for micro wiggles" property is preserved. +float cableArcDh(vec2 a, vec2 d, vec2 perpAB, float lenAB) { + if (lenAB <= ARC_MIN_LEN) return 0.0; + float dhSum = 0.0; + for (int j = 0; j < 5; j++) { + float tj = (float(j) + 0.5) * (1.0 / 5.0); // 0.1, 0.3, 0.5, 0.7, 0.9 + vec2 mj = a + d * tj; + float hL = heightAtWorldPos(mj - perpAB * ARC_PROBE_DIST); + float hR = heightAtWorldPos(mj + perpAB * ARC_PROBE_DIST); + dhSum += (hR - hL); + } + return dhSum * (1.0 / 5.0); +} + +// Returns the arc-biased centerline point at parameter t along the chord. +// `dh` is the cable-global signed pull magnitude from cableArcDh(). +// +// Pull saturation: rather than a linear gain (which left visibly steep +// terrain only weakly arched, then reverted to chord beyond budget), we +// smoothstep from 0 to maxDev as |dh| grows from 0 → ARC_DH_SAT. So as +// soon as there's any meaningful slope, the cable commits to the maximum +// allowed lateral deviation — it goes "as far around the hill as the +// arc budget permits" rather than reverting to the steep chord. +vec2 arcBiasedCenter(vec2 a, vec2 d, vec2 perpAB, float t, float lenAB, float dh) { + vec2 base = a + d * t; + if (lenAB <= ARC_MIN_LEN) return base; + float tent = 4.0 * t * (1.0 - t); + float maxDev = lenAB * ARC_MAX_DEV_FRAC * tent; + float pull = sign(dh) * maxDev * smoothstep(0.0, ARC_DH_SAT, abs(dh)); + // dh>0 (right higher) → pull base toward left = -perpAB * |pull|. + return base - perpAB * pull; +} + +// Wiggly cable point at chord parameter t — arc-biased centerline plus +// high-frequency noise. Used by both main ribbon (per-segment) and twig +// emitter (at spawn) so they sit on the same path. +// +// `perpCanon` orients the noise offset to a chord-direction-independent +// half-plane so the wiggle hits the same world position regardless of +// which endpoint is treated as "a". `arcBiasedCenter` is already +// direction-symmetric on its own (perpAB and dh both flip together). +vec2 wigglyCablePoint(vec2 a, vec2 d, vec2 perpAB, float t, float lenAB, + float arcDh, float effAmp, float seed) { + vec2 base = arcBiasedCenter(a, d, perpAB, t, lenAB, arcDh); + float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(t); + vec2 perpCanon = perpAB; + if (perpCanon.x < 0.0 || (perpCanon.x == 0.0 && perpCanon.y < 0.0)) { + perpCanon = -perpCanon; + } + return base + perpCanon * n; +} + +// Lift y to the max heightmap value sampled within ±fullStep along dirH. +// Linear interpolation between adjacent segment vertices can dip below +// terrain on convex/rolling slopes — taking the max within a window that +// covers the next vertex's position guarantees adjacent envelopes overlap +// at the segment midpoint, so the rendered ribbon stays above any peak in +// the gap. Used by the main ribbon (centerline lift) and twig emitter +// (spawn point lift) so they share the same vertical anchor. +float maxHeightInWindow(vec2 p, vec2 dirH, float fullStep) { + float yMax = heightAtWorldPos(p); + yMax = max(yMax, heightAtWorldPos(p + dirH * (fullStep * 0.30))); + yMax = max(yMax, heightAtWorldPos(p - dirH * (fullStep * 0.30))); + yMax = max(yMax, heightAtWorldPos(p + dirH * (fullStep * 0.55))); + yMax = max(yMax, heightAtWorldPos(p - dirH * (fullStep * 0.55))); + yMax = max(yMax, heightAtWorldPos(p + dirH * (fullStep * 0.85))); + yMax = max(yMax, heightAtWorldPos(p - dirH * (fullStep * 0.85))); + return yMax; +} + +void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, + float halfW, float widthVal, float effAmp, float seed, + vec4 gridD, vec2 timeD, float cap, int numSeg, float arcDh) { + gOutBranch = 0.0; + // `along` is fed into the FS as cableUV.x and drives bubble advection. + // It MUST be a 3D arc length, otherwise downslope cables look like the + // flow is racing because the same 2D Δalong covers more visible meters. + float along = 0.0; + vec3 prev3D = vec3(0.0); + float lenAB = length(d); + + // Cross-section basis — computed ONCE for the whole cable. Earlier we built + // N/T3/B3 per-vertex from `terrainNormal(p)`. That made adjacent vertices + // disagree about which way is "+B3" whenever the local terrain normal + // rotated between them (rolling terrain, hilltops, cross-slope crossings). + // Adjacent vertices' left/right edges then sat at slightly different + // rotational positions around the cable axis, so the ribbon physically + // twisted between them — visible as a corkscrew. The lighting was already + // correct; the geometry was twisted. + // + // Anchoring the basis to a chord-averaged Navg gives every vertex the SAME + // "+B3" direction. Per-vertex slope tilt still happens via the side-clamp + // (each side vertex independently lifted to local terrain+clearance), so + // the ribbon still appears to follow the slope — it just can't rotate + // around its own axis between segments. + vec3 Navg; + { + vec3 nAcc = vec3(0.0); + for (int j = 0; j < 5; j++) { + float tj = (float(j) + 0.5) * (1.0 / 5.0); + nAcc += terrainNormal(a + d * tj); + } + Navg = normalize(nAcc); + } + vec3 cableDirH_g = normalize(vec3(d.x, 0.0, d.y)); + vec3 T3_g = cableDirH_g - dot(cableDirH_g, Navg) * Navg; + float T3gL = length(T3_g); + T3_g = (T3gL > 1e-4) ? T3_g / T3gL : cableDirH_g; + vec3 B3 = normalize(cross(Navg, T3_g)); + vec3 perpRefH = normalize(vec3(-perpAB.x, 0.0, -perpAB.y)); + if (dot(B3, perpRefH) < 0.0) B3 = -B3; + + vec2 dirH = (lenAB > 0.0) ? d / lenAB : vec2(1.0, 0.0); + float fullStep = lenAB / float(numSeg); // full segment span + + for (int i = 0; i <= numSeg; i++) { + float t = float(i) / float(numSeg); + vec2 p = wigglyCablePoint(a, d, perpAB, t, lenAB, arcDh, effAmp, seed); + + // Anti-underground (along-cable): see maxHeightInWindow comment. + float yC = maxHeightInWindow(p, dirH, fullStep) + CENTERLINE_CLEAR; + vec3 center3D = vec3(p.x, yC, p.y); + + // Geometry convention MUST match the twig emitter: + // v = -1 → vertex at center − B3*halfW (so outward = −B3) + // v = +1 → vertex at center + B3*halfW (so outward = +B3) + // The FS reconstructs perp3D ≈ B3, then cylNormal = perp3D * v at the + // side, which therefore matches the *actual* outward direction. Prior + // version had these swapped, which inverted the lit side relative to + // the sun on every cable (and was inconsistent with twigs). + vec3 leftPos = center3D - B3 * halfW; + vec3 rightPos = center3D + B3 * halfW; + + // Anti-underground clamp: on terrain that curves up faster than linear + // (concave cross-slope), the slope-tangent-plane offset can put L or R + // below the actual heightmap at their XZ. Raise to local terrain + + // SIDE_CLEAR whenever that happens. On linear terrain the L/R points + // already sit at clearance above ground so this is a no-op there. + leftPos.y = max(leftPos.y, heightAtWorldPos(leftPos.xz) + SIDE_CLEAR); + rightPos.y = max(rightPos.y, heightAtWorldPos(rightPos.xz) + SIDE_CLEAR); + + // Also raise center3D if a clamp lifted the sides above it (preserves the + // cylinder appearance — center should never sit below a side vertex). + float midY = max(center3D.y, 0.5 * (leftPos.y + rightPos.y)); + center3D.y = midY; + + // Per-vertex tangent: forward-diff at vertex 0 (chord direction), and + // back-diff for subsequent vertices (centerline direction from the + // previous vertex). Smoothly interpolated across the triangle strip, + // this gives the FS a continuous cable along-direction so the + // cylinder normal bends with up/down hills. (Geometry itself is rigid: + // adjacent vertices share the same B3 cross-direction, so the ribbon + // cannot twist around its axis.) + vec3 vtxTangent; + if (i == 0) { + vtxTangent = cableDirH_g; + } else { + vtxTangent = center3D - prev3D; + float vtL = length(vtxTangent); + vtxTangent = (vtL > 1e-4) ? vtxTangent / vtL : cableDirH_g; + } + + if (i > 0) along += distance(prev3D, center3D); + prev3D = center3D; + + emitVtx(leftPos, vtxTangent, vec2(along, -1.0), widthVal, gridD, timeD, cap); + emitVtx(rightPos, vtxTangent, vec2(along, 1.0), widthVal, gridD, timeD, cap); + } + EndPrimitive(); +} + +// Emit a small lateral twig at parametric position tCenter along the main +// (wiggly) cable, deterministic on the cable seed + tCenter so the same +// twigs appear every frame in the same place. Returns silently when the +// hash says "no twig here" — leaving an empty primitive, which is a no-op. +void emitTwig(vec2 a, vec2 d, vec2 perpAB, + float halfMainW, float widthVal, float effAmp, float seed, + vec4 gridD, vec2 timeD, float cap, float tCenter, float invSeed, + float spawnAlongMain, int twigIdx, float arcDh, int numSeg) { + // Resolve spawn point on the wiggly main path at tCenter so twigs root on + // the visible cable. + float lenAB = length(d); + vec2 spawn = wigglyCablePoint(a, d, perpAB, tCenter, lenAB, arcDh, effAmp, seed); + + float twigSeed = spawn.x * 7.13 + spawn.y * 3.77 + invSeed; + float chance = gsHashU(spawn.x, spawn.y, twigSeed); + if (chance > BRANCH_CHANCE) return; + + // Side: STRICTLY alternate by twigIdx so neighbouring twigs along the + // main cable land on opposite sides. Two same-side adjacent twigs flashing + // in lockstep look like a single pulse "bouncing" — alternating sides + // breaks that visual coupling. Angle is still hash-randomised below. + float side = ((twigIdx & 1) == 0) ? 1.0 : -1.0; + float angleOff = BRANCH_ANGLE_MIN + + gsHashU(spawn.x, spawn.y, twigSeed + 2.0) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN); + float bLen = BRANCH_LEN_MIN + + gsHashU(spawn.x, spawn.y, twigSeed + 3.0) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN); + + float twigW = max(2.5, widthVal * BRANCH_WIDTH); + float twigHWr = min(twigW, widthVal * 0.55) * WIDTH_FACTOR; + // Geometric cone taper at 0.45 — visible shape narrows toward the tip + // (looks like a branch, not a tube). The WIDTH varying we pass to the FS + // stays UNIFORM at `twigW` along the entire twig, so bubble math sees + // constant halfWidthE and bubble radius/spacing don't change with along + // position. The visible bubble naturally fits the tapered geometry: in v + // space the bubble keeps the same cross-axis extent (relative to the + // cable's UV cross), which projects to a smaller world-cross at the + // thinner tip. At the very end the cable's `t > 0.9` cross discard clips + // any bubble that runs off the tip. This decouples "bubble flow looks + // uniform" from "twig has cone shape". + float twigHWt = twigHWr * 0.45; + + // Build the twig as a flat ribbon in the slope's local tangent plane at + // the spawn point. This way, viewing perpendicular to the slope, the twig + // looks exactly like a flat-ground twig — no downhill tilt artefact. + // + // Basis: N = terrain normal at spawn; T = cable tangent projected into the + // slope plane; B = N × T (in-slope perp to cable). Twig direction is + // (cos(angleOff)*T + side*sin(angleOff)*B), and twigPerp3D = N × twigDir3D. + vec3 N = terrainNormal(spawn); + vec3 cableDirH = normalize(vec3(d.x, 0.0, d.y)); + vec3 T = normalize(cableDirH - dot(cableDirH, N) * N); + vec3 B = normalize(cross(N, T)); + + float ca = cos(angleOff); + float sa = sin(angleOff) * side; + vec3 twigDir3D = ca * T + sa * B; + vec3 twigPerp3D = normalize(cross(N, twigDir3D)); + + // Anchor spawn to the same max-of-window lift the main ribbon uses, so the + // twig roots on the visible trunk. TWIG_CLEAR is slightly less than + // CENTERLINE_CLEAR so the junction sits just under the trunk's centerline + // (z-fight avoidance, see TWIG_CLEAR comment). + vec2 dirH = (lenAB > 0.0) ? d / lenAB : vec2(1.0, 0.0); + float fullStep = lenAB / float(numSeg); + float spawnYc = maxHeightInWindow(spawn, dirH, fullStep) + TWIG_CLEAR; + vec3 spawn3D = vec3(spawn.x, spawnYc, spawn.y); + + // Anchor the root to the spawn-side edge of the cable's in-slope cross + // section so the twig pokes out of the side, not the midline. + vec3 root3D = spawn3D + B * (halfMainW * 0.45 * side); + vec3 tip3D = root3D + twigDir3D * bLen; + + vec3 rootL = root3D - twigPerp3D * twigHWr; + vec3 rootR = root3D + twigPerp3D * twigHWr; + vec3 tipL = tip3D - twigPerp3D * twigHWt; + vec3 tipR = tip3D + twigPerp3D * twigHWt; + + // cableUV.x carries the cable-wide along distance so the FS growth gate + // hides this twig until the main growth front has reached spawnAlongMain. + // vsTangent for twigs is the twigDir3D (the twig's along-direction); the + // FS derives perp3D from cross(worldUp, vsTangent) so cylindrical lighting + // follows the twig's pointing direction. + gOutBranch = 1.0; + gOutSpawnAlong = spawnAlongMain; // shared by all 4 twig vertices; lets FS compute twig-local along + emitVtx(rootL, twigDir3D, vec2(spawnAlongMain, -1.0), twigW, gridD, timeD, cap); + emitVtx(rootR, twigDir3D, vec2(spawnAlongMain, 1.0), twigW, gridD, timeD, cap); + emitVtx(tipL, twigDir3D, vec2(spawnAlongMain + bLen, -1.0), twigW, gridD, timeD, cap); + emitVtx(tipR, twigDir3D, vec2(spawnAlongMain + bLen, 1.0), twigW, gridD, timeD, cap); + EndPrimitive(); + gOutSpawnAlong = 0.0; +} + +void main() { + // IMPORTANT: parent/child orientation is gameplay info — the bubble + // advection direction (driven by cableUV.x growing from a to b) signals + // power flow, so we MUST keep the synced parent→child order. The wiggle + // is made direction-independent below via a symmetric `seed`, which + // prevents the live→ghost transition from teleporting the noise pattern + // when MST reroutes flip parent/child while preserving the flow visual. + vec2 a = dataIn[0].vsWorldXZ; + vec2 b = dataIn[1].vsWorldXZ; + vec2 d = b - a; + float lenAB = length(d); + if (lenAB < 0.5) return; + vec2 dirAB = d / lenAB; + vec2 perpAB = vec2(-dirAB.y, dirAB.x); + + float cap = dataIn[0].vsCableData.x; + vec2 timeD = dataIn[0].vsCableData.yz; + vec4 gridD = dataIn[0].vsGridData; + + // Ghost edges (gridData.w = -1.0) emit only the main ribbon (no twigs), + // using the SAME wiggly path as live so the live→ghost transition has + // no visual snap. Ghost FS path is fast (no lighting/bubble math), and + // the GS still skips 4 of 5 invocations (twigs). Coverage updates use + // the live atomicAnd path with the wiggly samples → consistent with + // what the player visually sees. + bool isGhostEdge = gridD.w < -0.5; + if (isGhostEdge && gl_InvocationID > 0) return; + + float widthVal = MIN_TRUNK_WIDTH + + clamp(cap / MAX_CAPACITY_REF, 0.0, 1.0) * (MAX_TRUNK_WIDTH - MIN_TRUNK_WIDTH); + float halfW = widthVal * WIDTH_FACTOR; + float effAmp = NOISE_AMP_ABS * (lenAB < 80.0 ? (lenAB / 80.0) : 1.0); + // Symmetric seed: same multiplier on both endpoints so reversing (a,b) + // gives the same value. Keeps the wiggle stable across MST reroutes + // that flip parent/child orientation. Direction-dependent visuals (flow + // bubbles) are driven by cableUV.x which still respects the parent→child + // order, so flow direction is unaffected. + float seed = (a.x + b.x) * 0.215 + (a.y + b.y) * 0.621; + + // Coarse 3D length: 6 sub-spans of the straight a→b path, summing the + // terrain-aware Euclidean distance between samples. Slopes inflate len3D + // versus lenAB, so hilly cables get more turns AND tighter 2D spacing per + // segment (because each segment is len3D/numSeg in 3D arc, but spaced + // uniformly in 2D parameter t). Noise wiggle is ignored here — keeping the + // scan cheap matters more than a few % accuracy on segment count. + // + // Also tracks slope curvature: if the second derivative of height along + // the chord is large (terrain undulates rather than ramps), bump segment + // count further so the linear interpolation between vertices doesn't dip + // underground between samples. + float len3D = 0.0; + float curv = 0.0; + { + float h0 = heightAtWorldPos(a) + 2.0; + vec3 prev3 = vec3(a.x, h0, a.y); + float prevDy = 0.0; + for (int j = 1; j <= 6; j++) { + float tj = float(j) * (1.0 / 6.0); + vec2 bj = a + d * tj; + float hj = heightAtWorldPos(bj) + 2.0; + vec3 p3 = vec3(bj.x, hj, bj.y); + len3D += distance(p3, prev3); + float dy = hj - prev3.y; + if (j > 1) curv += abs(dy - prevDy); + prevDy = dy; + prev3 = p3; + } + } + // Bump segment count by curvature: every 6 elmos of cumulative |Δslope| + // adds one extra segment, capped at MAX_SEGMENTS. + int baseSeg = int(len3D / SEG_LEN_TARGET + 0.5); + int curvSeg = int(curv * (1.0 / 6.0)); + int numSeg = clamp(baseSeg + curvSeg, 1, MAX_SEGMENTS); + + // One global pull direction per cable: averaged dh across 5 chord anchors. + // Per-segment probing was the source of zigzag — see cableArcDh comment. + // Skipped for ghosts (10 heightmap probes) — they don't arc, so 0 is fine. + float arcDh = isGhostEdge ? 0.0 : cableArcDh(a, d, perpAB, lenAB); + + gOutSlot = dataIn[0].vsSlot; + // Pack lenPerSeg into gOutSpawnAlong for the main-ribbon emit (twigs reset + // it to their own value inside emitTwig). See DataGS comment for packing. + gOutSpawnAlong = (numSeg > 0) ? (len3D / float(numSeg)) : 1.0; + + // Ghost and live cables emit the same ribbon shape so live→ghost has no + // visual snap. Twig invocations already skipped above, and the FS takes + // a fast path for ghost fragments (no lighting/bubble math), so cost is + // bounded. + + if (gl_InvocationID == 0) { + // Coverage SSBO update — once per cable per frame, not per fragment. + // Live edges (gridData.w >= -0.5) atomicOr bits for segments currently + // in LOS; ghost edges (gridData.w < -0.5) atomicAnd to clear bits the + // player has re-scouted. Sampling along the actual wiggly path keeps + // reveal accurate even with arc bias on slopes. + // + // Saturation skip: read once and bail out when there's nothing to do. + // Live edges with all bits set will never get more bits → skip the + // LOS scan entirely. Ghost edges with all bits clear have nothing + // left to clear → skip too. Massive savings on long-running matches + // where most live cables hit saturation quickly. + int slot = dataIn[0].vsSlot; + bool isGhost = gridD.w < -0.5; + // Hard gate on the user-facing ghosts toggle — skip ALL coverage + // bookkeeping (the n-tap LOS scan, atomic ops, even the SSBO read) + // when ghosts are off. Restores live-only perf parity with pre-slice-1. + if (slot >= 0 && ghostsEnabled >= 0.5) { + int n = min(numSeg, 24); + uint fullMask = (n >= 32) ? 0xFFFFFFFFu : ((1u << uint(n)) - 1u); + uint cur = cableCoverage[slot].x; + bool skip = isGhost ? (cur == 0u) : ((cur & fullMask) == fullMask); + if (!skip) { + uint setMask = 0u; + uint clrMask = 0u; + for (int i = 0; i < n; i++) { + float t = (float(i) + 0.5) / float(numSeg); + vec2 p = wigglyCablePoint(a, d, perpAB, t, lenAB, arcDh, effAmp, seed); + vec2 losUV = clamp(p, vec2(0.0), mapSize.xy) / mapSize.zw; + float los = texture(infoTex, losUV).r; + if (los >= 0.5) { + if (isGhost) clrMask |= (1u << uint(i)); + else setMask |= (1u << uint(i)); + } + } + if (setMask != 0u) atomicOr (cableCoverage[slot].x, setMask); + if (clrMask != 0u) atomicAnd(cableCoverage[slot].x, ~clrMask); + } + } + emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg, arcDh); + } else { + if (isGhostEdge) return; // ghosts skip twig invocations entirely + // Twig density scales with 3D arc length: ~one twig per 110 elmos, + // capped at 4. Short cables get 0-1 twigs, long ones get the full set. + // Surviving twigs are then respread across [0.15, 0.85] so spacing + // remains roughly even regardless of twig count. + int idx = gl_InvocationID - 1; // 0..3 + int expectedTwigs = clamp(int(len3D / 85.0 + 0.5), 0, 4); + if (idx >= expectedTwigs) return; + float tCenterRaw = 0.15 + (float(idx) + 0.5) * (0.7 / float(expectedTwigs)); + // Snap to a main-ribbon segment vertex. The cable is rendered as + // piecewise-linear chords between samples at t = i/numSeg, so anchoring + // the twig at the analytical centerline (which curves between samples) + // would leave the root edge floating off the visible cable surface. + // Snapping makes the spawn point coincide with an actual rendered + // vertex of the main ribbon. + float tCenter = clamp(round(tCenterRaw * float(numSeg)), 1.0, float(numSeg) - 1.0) + / float(numSeg); + float spawnAlongMain = len3D * tCenter; + emitTwig(a, d, perpAB, halfW, widthVal, effAmp, seed, + gridD, timeD, cap, tCenter, float(idx) * 13.7, spawnAlongMain, idx, arcDh, numSeg); + } +} diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl new file mode 100644 index 0000000000..422e45f989 --- /dev/null +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl @@ -0,0 +1,33 @@ +#version 430 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require +#extension GL_ARB_shader_storage_buffer_object : require + +// Pass-through VS: each cable is a single GL_LINES primitive (2 vertices, +// both carrying the same per-edge attributes). The geometry shader expands +// the line into a wiggly noisy ribbon with N segments. All the expensive +// per-vertex math that used to live on the CPU now lives on the GPU. + +layout (location = 0) in vec2 vertPos; // (x, z) world coords +layout (location = 1) in vec3 vertData; // (capacity, appearTime, witherTime) +layout (location = 2) in vec4 vertGrid; // (gridEfficiency, flow, bubblePhase, isOwnAlly) +layout (location = 3) in float vertSlot; // coverage SSBO slot (-1 = disabled) + +out gl_PerVertex { + vec4 gl_Position; +}; + +out DataVS { + vec2 vsWorldXZ; + vec3 vsCableData; + vec4 vsGridData; + flat int vsSlot; +}; + +void main() { + vsWorldXZ = vertPos; + vsCableData = vertData; + vsGridData = vertGrid; + vsSlot = int(vertSlot); + gl_Position = vec4(0.0); +} diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua new file mode 100644 index 0000000000..56b0e93b31 --- /dev/null +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -0,0 +1,2951 @@ +------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------- +-- Overdrive Cable Tree Visualization +-- Synced: maintains topology + per-edge capacity, sends Full/Delta to unsynced. +-- Unsynced: organic-tree geometry, gameframe-based grow/wither animation in shader. +------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------- + +function gadget:GetInfo() + return { + name = "Overdrive Cable Tree", + desc = "Visualizes overdrive grid as cables drawn on ground texture", + author = "Licho", + date = "2026", + license = "GNU GPL, v2 or later", + layer = -3, + enabled = true, + } +end + +-- Pure-visualization gadget: nothing here affects simulation, so we skip the +-- synced sandbox entirely. Unsynced gadgets still receive UnitCreated / +-- UnitDestroyed / UnitGiven for ALL units regardless of LOS (unlike widgets), +-- which is what we need to keep ghost cables alive after enemy pylons leave +-- LOS. Since each client's unsynced sandbox sees the same engine state and +-- runs the same code, every client independently reaches the same topology +-- without any synced→unsynced channel. +if gadgetHandler:IsSyncedCode() then return false end + +-- Forward declaration: SendAll (topology side, defined below) hands its +-- per-ally snapshot directly to OnCableTreeFull (rendering side, defined +-- much further down). Both are file-scope locals; the body assignment for +-- OnCableTreeFull happens in the rendering section. +local OnCableTreeFull + +------------------------------------------------------------------------------------- +-- Topology + flow computation (was previously the synced half). +-- Reads gridNumber from unit_mex_overdrive as source of truth. +-- Periodically computes desired spanning tree edges per grid; OnCableTreeFull +-- below consumes the result directly (no sandbox crossover). +------------------------------------------------------------------------------------- + +local spGetUnitPosition = Spring.GetUnitPosition +local spGetUnitAllyTeam = Spring.GetUnitAllyTeam +local spGetUnitDefID = Spring.GetUnitDefID +local spGetUnitRulesParam = Spring.GetUnitRulesParam +local spGetUnitIsStunned = Spring.GetUnitIsStunned +local spValidUnitID = Spring.ValidUnitID +local spGetUnitResources = Spring.GetUnitResources + +-- Mirrors the "currentlyActive" check in unit_mex_overdrive.lua so we only +-- show cables for pylons actually contributing to the grid. GetUnitIsStunned +-- covers under-construction, EMP'd, and transported units. +local function IsActiveForGrid(unitID) + if spGetUnitIsStunned(unitID) then return false end + if spGetUnitRulesParam(unitID, "disarmed") == 1 then return false end + if spGetUnitRulesParam(unitID, "morphDisable") == 1 then return false end + return true +end + +local sqrt = math.sqrt +local max = math.max +local floor = math.floor + +------------------------------------------------------------------------------------- +-- Config +------------------------------------------------------------------------------------- + +local SYNC_PERIOD = 30 -- frames between grid sync (~1/s); also send cadence +local DEBUG_FLOW = false -- echo per-edge capacity table on every Send (chatty) +-- Spanning-tree topology mode: +-- "euclidean" visually-pleasing layout — every pair of pylons in the same +-- grid is a candidate edge (subject to MST_CANDIDATE_R), so +-- co-linear chains form naturally and long-range pylons don't +-- fan into stars. Cables may not match actual pylon-to-pylon +-- links the engine uses internally. +-- "realistic" cables only between pylons whose pylon ranges actually reach +-- each other (the engine's own connectivity graph). Faithful +-- to physical wiring; can produce hub-fan stars and miss +-- trunk-sharing opportunities. +local MST_MODE = "realistic" + +------------------------------------------------------------------------------------- +-- Unit definitions +------------------------------------------------------------------------------------- + +local pylonDefs = {} +local mexDefs = {} +local generatorDefs = {} +local pmaxByDef = {} -- [defID] = nameplate production for non-wind generators +local isWindgenByDef = {} -- [defID] = true (production resolved via WindMax at runtime) +local voltageByDef = {} -- [defID] = neededlink value (counts as static Dmax) +-- Per-tick consumer set: only nodes whose def could plausibly draw current +-- get hit with Spring.GetUnitResources / overdrive_energyDrain reads each +-- ComputeMaxPotentials cycle. Pure generators (windmill, solar, fusion) and +-- range-only pylons are skipped — at 1500+ pylons that read alone took the +-- bulk of the 1Hz hitch. +local consumerByDef = {} + +for i = 1, #UnitDefs do + local udef = UnitDefs[i] + local cp = udef.customParams + local pylonRange = tonumber(cp.pylonrange) or 0 + if pylonRange > 0 then + pylonDefs[i] = pylonRange + end + if cp.metal_extractor_mult then + mexDefs[i] = true + end + local energyIncome = tonumber(cp.income_energy) or 0 + local isWind = (cp.windgen and true) or false + if energyIncome > 0 or isWind then + generatorDefs[i] = true + end + if energyIncome > 0 then + pmaxByDef[i] = energyIncome + end + if isWind then + isWindgenByDef[i] = true + end + local nl = tonumber(cp.neededlink) + if nl and nl > 0 then + voltageByDef[i] = nl + end + -- Mex / voltage unit / anything that builds (factory, strider hub, + -- builder commander, etc.) can draw energy on the cable. + local hasBuildPower = (udef.buildSpeed and udef.buildSpeed > 0) or + (udef.buildPower and udef.buildPower > 0) + if mexDefs[i] or voltageByDef[i] or hasBuildPower then + consumerByDef[i] = true + end +end + +-- Mex draw treated as effectively unbounded for max-potential math. Large +-- enough that min(Pmax, INF_DRAW) collapses to Pmax cleanly, small enough to +-- survive float subtraction (totalDmax - subtreeDmax) without precision loss. +local INF_DRAW = 1e9 + +-- WindMax is set by unit_windmill_control.lua at game start; resolve lazily. +local cachedWindMax +local function GetWindMax() + if cachedWindMax then return cachedWindMax end + local v = Spring.GetGameRulesParam("WindMax") + if v then cachedWindMax = v end + return v or 2.5 +end + +------------------------------------------------------------------------------------- +-- State +------------------------------------------------------------------------------------- + +-- All tracked pylons per allyTeam: nodes[allyTeamID][unitID] = {x, z, range, unitDefID} +local nodes = {} + +-- Reverse index: unitID -> allyTeamID, for O(1) edge->ally lookup during send +local allyOfUnit = {} + +-- Edges: edges[edgeKey] = {parentID, childID, px, pz, cx, cz} +-- Visual progress (grow/wither) is unsynced-only, gameframe-driven. +local edges = {} + +-- Change detection +local lastGridNum = {} -- [unitID] = gridNumber +local topologyDirty = false -- set true when SyncWithGrid actually adds or removes an edge +local alliesWithEdges = {} -- [ally] = true if last send had edges (for empty-clear) + +-- Cached MSTs per (ally, gridID): only rebuilt when membership of that grid +-- actually changes. SyncWithGrid composes the desired edge set from this cache. +local mstByGrid = {} -- [gridKey] = { ally, gridID, edges = {ek = einfo} } + +-- Grids that need a rebuild on the next SyncWithGrid call. Sync also adds +-- entries it discovers itself by diffing rules-params against lastGridNum. +local pendingGridDirty = {} -- [gridKey] = { ally, gridID } + +-- Flat unitID -> unitDefID map; saves the per-call ally scan in +-- ComputeMaxPotentials' nodeUnitDefID (which used to walk every ally's nodes +-- table per node per tick). +local nodeDefByUID = {} -- [unitID] = unitDefID + +-- Per-unit static cache: minWind (set once by unit_windmill_control at unit +-- creation, never changes thereafter). Without this, BuildMpCache re-reads +-- Spring.GetUnitRulesParam("minWind") for every windmill on every topology +-- change — at 3500 windmills that's ~3.5ms of cross-boundary calls, fired on +-- every cascade tick during destruction events. Cache on first read; drop +-- on UnitDestroyed (handled where nodeDefByUID is cleared). +local minWindByUID = {} -- [unitID] = cached minWind value (E/s) +local function GetCachedMinWind(uid) + local v = minWindByUID[uid] + if v ~= nil then return v end + v = spGetUnitRulesParam(uid, "minWind") or 0 + minWindByUID[uid] = v + return v +end + +-- Index of pylon-eligible CONSUMER units only (mexes, voltage units, builders). +-- Maintained on UnitCreated / SyncWithGrid death-sweep / UnitGiven so SendAll +-- can do a cheap O(consumers) pre-check (~50 reads at 4000 nodes) instead of +-- always running the O(N) ComputeMaxPotentials. Generators (windmills/solar/ +-- fusion) never appear here; only nodes whose defs publish a non-zero draw. +local consumerNodeIndex = {} -- [unitID] = unitDefID +local lastConsumerDcur = {} -- [unitID] = last-seen Dcurrent reading +local lastWindFrac = -1 -- last-tick windFrac for change detection + +local function GetCurWindFrac() + local f = Spring.GetGameRulesParam("WindStrength") or 0 + if f < 0 then return 0 elseif f > 1 then return 1 end + return f +end + +-- Spatial-hash and candidate-cap constants. Declared early so the pylon- +-- neighbour helpers below capture them as upvalues. Re-referenced (without +-- redeclaration) by BuildGridMSTFromScratch and the incremental MST ops. +local SPATIAL_CELL = 2000 -- cell size; 3x3 covers ~4000-elmo pairs +local MST_CANDIDATE_R = 4000 -- hard cap on candidate-pair distance +local MST_CANDIDATE_R_SQ = MST_CANDIDATE_R * MST_CANDIDATE_R +local MST_EUCLIDEAN_MODE = MST_MODE == "euclidean" + +-- Global precomputed neighbour index. The MST candidate-set for any pylon is +-- a function ONLY of (positions, ranges) of nearby same-ally pylons — all +-- static once a pylon exists. So we compute it once on UnitCreated and reuse +-- on every MST build/update. +-- +-- Without this, BuildGridMSTFromScratch was rebuilding neighbour lists from +-- the spatial hash on every call: 3×3 cells × cellsize candidates × N pylons. +-- In dense scenes (4000 windmills @ 110 elmo spacing → ~325 pylons per +-- 2000-elmo cell → 3000 candidates per pylon) that's 12M iterations per +-- rebuild — the dominant cost. With cached neighbours, MST builders just +-- iterate `pylonNeighbours[uid]` (typical degree 20-50) and filter by grid +-- membership. +-- +-- Bidirectional: pylonNeighbours[a][b] and pylonNeighbours[b][a] are both +-- set, both with the same distSq. Same-ally only. +local pylonNeighbours = {} -- [uid] = { [otherUid] = distSq } + +-- Per-ally spatial hash maintained alongside `nodes`. Used to find neighbour +-- candidates when a pylon is created — ONE 3×3-cell scan against the live +-- ally hash, then bidirectional add. Subsequent MST work consumes +-- pylonNeighbours directly without ever touching the hash. +local pylonSpatialHash = {} -- [allyID] = { [cellKey] = { uid1, ... } } + +local function PylonCellKey(x, z) + return floor(x / SPATIAL_CELL) * 100000 + floor(z / SPATIAL_CELL) +end + +local function PylonAddSpatial(allyID, uid, x, z) + local hash = pylonSpatialHash[allyID] + if not hash then hash = {}; pylonSpatialHash[allyID] = hash end + local ck = PylonCellKey(x, z) + local cell = hash[ck] + if not cell then cell = {}; hash[ck] = cell end + cell[#cell + 1] = uid +end + +local function PylonRemoveSpatial(allyID, uid, x, z) + local hash = pylonSpatialHash[allyID] + if not hash then return end + local cell = hash[PylonCellKey(x, z)] + if not cell then return end + for i = 1, #cell do + if cell[i] == uid then + cell[i] = cell[#cell] + cell[#cell] = nil + return + end + end +end + +-- Walk the 3×3 spatial-hash neighbourhood of `uid`'s ally; for every other +-- pylon within candidate cap, write a bidirectional pylonNeighbours entry. +local function PylonBuildNeighbours(allyID, uid) + local allyNodes = nodes[allyID] + if not allyNodes then return end + local node = allyNodes[uid] + if not node then return end + local hash = pylonSpatialHash[allyID] + if not hash then return end + local nb = pylonNeighbours[uid] + if not nb then nb = {}; pylonNeighbours[uid] = nb end + local px, pz, pr = node.x, node.z, node.range + local cx = floor(px / SPATIAL_CELL) + local cz = floor(pz / SPATIAL_CELL) + local euclidean = MST_EUCLIDEAN_MODE + local rSq = MST_CANDIDATE_R_SQ + for dcx = -1, 1 do + for dcz = -1, 1 do + local cell = hash[(cx + dcx) * 100000 + (cz + dcz)] + if cell then + for ci = 1, #cell do + local j = cell[ci] + if j ~= uid then + local jnode = allyNodes[j] + if jnode then + local dx = px - jnode.x + local dz = pz - jnode.z + local distSq = dx * dx + dz * dz + local cap = euclidean and rSq + or ((pr + jnode.range) * (pr + jnode.range)) + if distSq < cap then + nb[j] = distSq + local other = pylonNeighbours[j] + if not other then other = {}; pylonNeighbours[j] = other end + other[uid] = distSq + end + end + end + end + end + end + end +end + +local function PylonClearNeighbours(uid) + local nb = pylonNeighbours[uid] + if not nb then return end + for n in pairs(nb) do + local other = pylonNeighbours[n] + if other then other[uid] = nil end + end + pylonNeighbours[uid] = nil +end + +-- mpCache: topology-stable cache used by ComputeMaxPotentials. Adjacency, +-- DFS visit order, parentInTree, per-component root, and *static* per-subtree +-- aggregates (Pmax, Dmax, plus wind-decomposed terms) are computed once per +-- topology and reused every tick. Per-tick the only work is: re-fetch live +-- draw rules-params, post-order accumulate subDcur, compute subPcur via the +-- wind formula, run min-cut math. +local mpCache = { + valid = false, +} + +do + local allyTeamList = Spring.GetAllyTeamList() + for i = 1, #allyTeamList do + nodes[allyTeamList[i]] = {} + end +end + +-- Detail level — three states, persisted under OverdriveCableDetail: +-- 0 = off (no cables drawn at all; clears geometry) +-- 1 = noflow (static lines; skips per-tick flow reads + FS bubble pass) +-- 2 = full (default: animated bubbles, per-tick flow updates) +-- The two derived flags (cableEnabled / cableFlowMode) drive existing code +-- paths unchanged; only the chat command + widget settings menu speak in +-- terms of the unified detail level. +local DETAIL_OFF, DETAIL_NOFLOW, DETAIL_FULL = 0, 1, 2 + +local function readDetailFromConfig() + local v = Spring.GetConfigInt("OverdriveCableDetail", DETAIL_FULL) or DETAIL_FULL + if v < DETAIL_OFF or v > DETAIL_FULL then v = DETAIL_FULL end + return v +end +local cableDetail = readDetailFromConfig() + +-- Runtime toggles, driven by the /cabletree chat command (see CableTreeCmd). +local cableEnabled = cableDetail ~= DETAIL_OFF +local cablePerf = false +local cableFlowMode = cableDetail == DETAIL_FULL +-- Ghost rendering toggle. When on: GS samples LOS at each cable segment and +-- atomicOr's bits into the per-edge coverage SSBO; FS gates the ghost branch +-- on those bits; a separate ghost VBO carries orphaned enemy edges as a +-- translucent overlay. When off: every code path short-circuits, restoring +-- pre-ghosts perf for live-only rendering. +local cableGhosts = (Spring.GetConfigInt("OverdriveCableGhosts", 1) or 1) ~= 0 + +-- Forward-declared ghost state. The actual table lives further down with the +-- rest of the orphaned-enemy snapshotting code, but CableTreeCmd (defined +-- above that block at line ~1879) needs to clear ghostEdges + flip +-- ghostNeedsRebuild when the user toggles ghosts off. Declaring `local` only +-- at the later definition site would shadow these as globals from the +-- function's POV — which is what caused the +-- "bad argument #1 to '(for generator)' (table expected, got nil)" errors +-- in callin=GotChatMsg when toggling /cabletree ghosts off. +local ghostEdges = {} +local ghostNeedsRebuild = false + +-- --------------------------------------------------------------------------- +-- Per-edge coverage SSBO +-- --------------------------------------------------------------------------- +-- Each cable owns one slot in `coverageSSBO`. The GS atomicOr's bit `i` +-- whenever segment `i` is currently in LOS during the live pass; for ghost +-- edges it atomicAnd's the bit off when the player re-scouts. The slot index +-- is allocated per-edgeKey and freed when the edge has no live presence and +-- the cleanup pass confirms the area is empty. +-- +-- Slot numbering is decoupled from edge identity (which is `EdgeKey = +-- min(uidA,uidB):max(uidA,uidB)`, persistent across topology rerouting): +-- the unitID-pair stays the same logical cable, and slotByKey maps that +-- string to a small numeric SSBO index suitable for shader array lookup. +-- +-- Layout note: Spring's VBO API requires vec4-aligned attributes for SSBO +-- definition. We declare a single 4-float column and only use .x as the +-- coverage uint (re-interpreted via uvec4 in the shader). The remaining 3 +-- floats per slot are unused — total cost ~64KB at COVERAGE_MAX_SLOTS=4096, +-- still trivial. +local COVERAGE_MAX_SLOTS = 4096 +local coverageSSBO -- gl.GetVBO(GL.SHADER_STORAGE_BUFFER); allocated in Initialize +local slotByKey = {} -- edgeKey -> slot +local freeSlots = {} -- stack of recycled slot IDs +local nextSlot = 0 -- next never-allocated slot + +local function AllocSlot(edgeKey) + local s = slotByKey[edgeKey] + if s then return s end + local n = #freeSlots + if n > 0 then + s = freeSlots[n]; freeSlots[n] = nil + else + if nextSlot >= COVERAGE_MAX_SLOTS then return nil end + s = nextSlot + nextSlot = nextSlot + 1 + end + slotByKey[edgeKey] = s + -- Recycled slots get zeroed at FreeSlot time, so AllocSlot doesn't need + -- to upload here. Initial allocation works too because the SSBO is + -- zero-initialised at gadget:Initialize. + return s +end + +local function FreeSlot(edgeKey) + local s = slotByKey[edgeKey] + if not s then return end + slotByKey[edgeKey] = nil + freeSlots[#freeSlots + 1] = s + -- Wipe the slot before it can be re-handed-out to a different edge. + -- Upload one vec4 of zeros at element offset s. Spring's Upload signature: + -- (data, attribIdx, elemOffset, luaStart, luaFinish). attribIdx=nil = + -- "all attribs" (we have one), elemOffset=s, explicit luaStart/luaFinish + -- avoid the engine's "too few data" check that fires when those default. + if coverageSSBO then + coverageSSBO:Upload({0, 0, 0, 0}, nil, s, 1, 4) + end +end + +-- Per-tick perf stats. Filled by SyncWithGrid / ComputeMaxPotentials / +-- SendAll only when cablePerf is on; RunSyncTick reads them and emits one +-- summary line per tick. Module-scope for zero-cost write paths when perf +-- is off (the writers gate on cablePerf themselves). +local perfStats = { + dropMs = 0, refreshMs = 0, mstMs = 0, mstRebuilds = 0, mstIncrements = 0, + composeMs = 0, diffMs = 0, + mpBuildMs = 0, mpComputeMs = 0, + binMs = 0, dispatchMs = 0, + skipped = 0, + -- Slowest single MST rebuild this tick: ms, member count, and a + -- breakdown of what the heap-Prim spent its time on. + worstRebuildMs = 0, worstRebuildN = 0, + worstRebuildHashMs = 0, worstRebuildNeighMs = 0, worstRebuildPrimMs = 0, + -- Slowest single incremental this tick: ms, members removed/added. + worstIncrMs = 0, worstIncrRem = 0, worstIncrAdd = 0, +} + +-- Last-sent snapshot of per-edge (flow, eff) so SendAll can short-circuit +-- when nothing meaningfully changed. Quiet ticks (settled grid, no draw +-- spikes) become near-zero on the topology side. +local lastSentFlow = {} -- [edgeKey] = flow (E/s) +local lastSentEff = {} -- [edgeKey] = grid efficiency +-- A send is forced if max relative flow change exceeds this OR any eff +-- changed by more than EFF_EPSILON. The thresholds are loose because +-- visual-flow only needs ballpark accuracy: bubble speed ∝ sqrt(flow), so +-- a 10% flow change is ~5% bubble-speed change — well below perception. +local FLOW_REL_EPSILON = 0.10 +local FLOW_ABS_EPSILON = 0.5 -- E/s, for tiny flows where relative is noisy +local EFF_EPSILON = 0.02 +-- Force a refresh at least this often even when stable, so any drift in +-- the FS phase extrapolation doesn't accumulate without bound. +local FORCE_SEND_TICKS = 5 -- ~5 seconds at SYNC_PERIOD=30 / 30fps +local ticksSinceSend = 0 + +------------------------------------------------------------------------------------- +-- Helpers +------------------------------------------------------------------------------------- + +local function EdgeKey(id1, id2) + if id1 < id2 then return id1 .. ":" .. id2 + else return id2 .. ":" .. id1 end +end + +local function GridKey(allyTeamID, gridID) + return allyTeamID .. ":" .. gridID +end + +-- Track per-tick membership changes per grid. SyncWithGrid then applies them +-- incrementally on top of the cached MST instead of rebuilding the whole grid +-- from scratch — Prim's full rebuild on a 4000-node grid is ~800ms; the +-- incremental path stays in the local-neighborhood of the affected nodes. +-- +-- pendingGridDirty[gk] = { ally, gridID, adds = { uid1, uid2, ... }, removes = { uid1, ... } } +-- The same uid never appears in both adds and removes for the same grid in +-- a single tick (membership flip is observed once in step 2 of SyncWithGrid). +local function GetPendingEntry(ally, gridID) + if not gridID or gridID <= 0 or not ally then return nil end + local gk = GridKey(ally, gridID) + local entry = pendingGridDirty[gk] + if not entry then + entry = { ally = ally, gridID = gridID, adds = {}, removes = {} } + pendingGridDirty[gk] = entry + end + return entry +end + +local function MarkGridAdd(ally, gridID, uid) + local entry = GetPendingEntry(ally, gridID) + if entry then entry.adds[#entry.adds + 1] = uid end +end + +local function MarkGridRemove(ally, gridID, uid) + local entry = GetPendingEntry(ally, gridID) + if entry then entry.removes[#entry.removes + 1] = uid end +end + +-- Backward-compat for callers (UnitGiven) that just want to flag a grid as +-- needing reconsideration without naming a specific unit (e.g. when a whole +-- pylon's affiliation changes). +local function MarkGridDirty(ally, gridID) + GetPendingEntry(ally, gridID) +end + +-- Stable nameplate production: solar/fusion/sing fixed; windgen = current WindMax. +local function GetNodePmax(unitDefID) + if isWindgenByDef[unitDefID] then return GetWindMax() end + return pmaxByDef[unitDefID] or 0 +end + +-- Static draw: mex = effectively infinite (any flow saturates it), +-- voltage units contribute their neededlink threshold. +local function GetNodeDmax(unitDefID) + if mexDefs[unitDefID] then return INF_DRAW end + return voltageByDef[unitDefID] or 0 +end + +-- Current real production: any generator publishes "current_energyIncome" +-- (windgens, solar, fusion, singu — all set by unit_mex_overdrive each tick). +local function GetNodePcurrent(unitID, unitDefID) + if not generatorDefs[unitDefID] then return 0 end + return spGetUnitRulesParam(unitID, "current_energyIncome") or 0 +end + +-- Current real draw. Mexes consume via the overdrive system, which the +-- mex_overdrive gadget reports per-unit as "overdrive_energyDrain". Every +-- *other* pylon-tracked unit (strider hubs building units, factories, +-- firing turrets, charging weapons, …) reports its live consumption via +-- Spring.GetUnitResources().energyUse — so that's the right quantity to +-- treat as cable draw at the consumer end. +-- +-- The two are mutually exclusive: mexes don't have direct energyUse for the +-- OD spend (the mex_overdrive gadget allocates from the team pool, not via +-- the mex's own use), and non-mex consumers don't have an +-- "overdrive_energyDrain" rules-param. +local function GetNodeDcurrent(unitID, unitDefID) + if mexDefs[unitDefID] then + return spGetUnitRulesParam(unitID, "overdrive_energyDrain") or 0 + end + local _, _, _, eUse = spGetUnitResources(unitID) + return eUse or 0 +end + +------------------------------------------------------------------------------------- +-- Per-grid MST. The MST cache is *incremental*: when a single pylon joins/ +-- leaves a grid we patch the local neighbourhood instead of rebuilding the +-- whole tree (Prim's full rebuild on a 4000-node grid is ~800ms; an +-- incremental remove-and-reconnect of one node is ~ms). +-- +-- Algorithm: +-- - Add: cheapest cross-edge from new node to current tree (Prim cut prop). +-- - Remove: cut all incident tree edges, identify the resulting components, +-- Borůvka-merge them back via cheapest cross-edges (using the +-- spatial hash so each merge is local-neighborhood-bounded). +-- - From scratch (new grid): Prim's expanding from the highest-production +-- seed; same code path as a "single batch add" into an empty MST. +-- +-- Spatial hashing gates candidate pairs to a generous radius so even huge +-- grids stay sub-quadratic; cell size is set so any realistic MST edge +-- falls within a 3x3 cell neighbourhood. +-- +-- mstByGrid[gk] = { ally, gridID, members = {[uid]=true}, edges = {[ek]=einfo}, adj = {[uid]={[neighbor]=true}} } +-- `adj` is the persistent undirected adjacency derived from `edges`, kept up +-- to date by MstAddEdge / MstRemoveEdge so incremental ops don't have to +-- rebuild it per call. +------------------------------------------------------------------------------------- + +-- (SPATIAL_CELL / MST_CANDIDATE_R_SQ / MST_EUCLIDEAN_MODE are declared near +-- the top of the file so the pylon-neighbour helpers can see them as upvalues.) + +-- Build a spatial hash over EVERY pylon in an ally team (not just the ones +-- in a particular grid). Reused across all dirty grids of that ally inside +-- a single SyncWithGrid call. cells[ck] = {uid, ...}, allyNodes[uid] = node. +local function BuildAllySpatialHash(allyTeamID) + local cells = {} + local allyNodes = nodes[allyTeamID] + if not allyNodes then return cells, nil end + for uid, node in pairs(allyNodes) do + local cx = floor(node.x / SPATIAL_CELL) + local cz = floor(node.z / SPATIAL_CELL) + local ck = cx * 100000 + cz + local cell = cells[ck] + if not cell then cell = {}; cells[ck] = cell end + cell[#cell + 1] = uid + end + return cells, allyNodes +end + +-- Distance² between two pylons; returns nil if the pair exceeds the candidate +-- cap (so the caller treats them as non-candidates). +local function CandidateDistSq(p, o) + local dx = p.x - o.x + local dz = p.z - o.z + local distSq = dx * dx + dz * dz + local cap = MST_EUCLIDEAN_MODE and MST_CANDIDATE_R_SQ + or ((p.range + o.range) * (p.range + o.range)) + if distSq >= cap then return nil end + return distSq +end + +-- Edge add/remove primitives that keep `mst.edges` and `mst.adj` in lock-step. +local function MstAddEdge(mst, fromUid, toUid, einfo) + mst.edges[EdgeKey(fromUid, toUid)] = einfo + local af = mst.adj[fromUid]; if not af then af = {}; mst.adj[fromUid] = af end + local at = mst.adj[toUid]; if not at then at = {}; mst.adj[toUid] = at end + af[toUid] = true + at[fromUid] = true +end + +local function MstRemoveEdge(mst, fromUid, toUid) + mst.edges[EdgeKey(fromUid, toUid)] = nil + local af = mst.adj[fromUid]; if af then af[toUid] = nil; if not next(af) then mst.adj[fromUid] = nil end end + local at = mst.adj[toUid]; if at then at[fromUid] = nil; if not next(at) then mst.adj[toUid] = nil end end +end + +-- Mint a fresh edge info record (the einfo shape that downstream consumers expect). +local function MakeEdgeInfo(fromUid, toUid, allyNodes) + local p1 = allyNodes[fromUid] + local p2 = allyNodes[toUid] + return { + parentID = fromUid, childID = toUid, + px = p1.x, pz = p1.z, cx = p2.x, cz = p2.z, + } +end + +-- Spatial-hash neighbour iteration: walks the 3×3 cell block around `uid` +-- and invokes `cb(j, distSq)` for every other pylon within candidate cap. +local function ForEachCandidate(uid, allyNodes, cells, cb) + local p = allyNodes[uid] + if not p then return end + local cx = floor(p.x / SPATIAL_CELL) + local cz = floor(p.z / SPATIAL_CELL) + for dcx = -1, 1 do + for dcz = -1, 1 do + local ck = (cx + dcx) * 100000 + (cz + dcz) + local cell = cells[ck] + if cell then + for ci = 1, #cell do + local j = cell[ci] + if j ~= uid then + local o = allyNodes[j] + if o then + local distSq = CandidateDistSq(p, o) + if distSq then cb(j, distSq) end + end + end + end + end + end + end +end + +-- Add a batch of new nodes to the MST via Prim's expansion. The current +-- members form the starting "tree"; pending adds are attached one at a +-- time by cheapest cross-edge. With existing tree as the frontier, this is +-- Prim correct for the merged member set (Cut Property: the cheapest edge +-- crossing any cut is in some MST, so picking cheapest pending↔tree at each +-- step yields an MST). +local function MstAddNodes(mst, addUids, allyNodes, cells) + if not allyNodes then return end + + local pending = {} -- [uid] = true + local toAddCount = 0 + for i = 1, #addUids do + local uid = addUids[i] + if not mst.members[uid] and allyNodes[uid] then + pending[uid] = true + toAddCount = toAddCount + 1 + end + end + if toAddCount == 0 then return end + + -- bestEdge[uid] = { distSq, fromUid } — uid is pending, fromUid is in tree. + local bestEdge = {} + -- Seed bestEdge for each pending against current members. + -- For each pending uid, walk its 3×3 cells and check existing members. + -- (If tree is currently empty, no seeding happens; we'll bootstrap below.) + if next(mst.members) then + for uid in pairs(pending) do + ForEachCandidate(uid, allyNodes, cells, function(j, distSq) + if mst.members[j] then + local cur = bestEdge[uid] + if not cur or distSq < cur.distSq then + bestEdge[uid] = { distSq = distSq, fromUid = j } + end + end + end) + end + end + + -- Prim expansion loop. + while toAddCount > 0 do + -- Pick cheapest pending uid that has a candidate edge. + local pickUid, pickDistSq = nil, math.huge + for uid in pairs(pending) do + local be = bestEdge[uid] + if be and be.distSq < pickDistSq then + pickUid, pickDistSq = uid, be.distSq + end + end + + if not pickUid then + -- No reachable pending. Either tree is empty (bootstrap) OR the + -- remaining pending are unreachable from the tree. In both cases + -- seed an arbitrary pending as a new member without an edge; the + -- next iterations will discover edges to it from the rest of the + -- pending pool via ForEachCandidate's neighbour update below. + local seed = next(pending) + mst.members[seed] = true + pending[seed] = nil + bestEdge[seed] = nil + toAddCount = toAddCount - 1 + + ForEachCandidate(seed, allyNodes, cells, function(j, distSq) + if pending[j] then + local cur = bestEdge[j] + if not cur or distSq < cur.distSq then + bestEdge[j] = { distSq = distSq, fromUid = seed } + end + end + end) + else + local fromUid = bestEdge[pickUid].fromUid + mst.members[pickUid] = true + pending[pickUid] = nil + bestEdge[pickUid] = nil + toAddCount = toAddCount - 1 + MstAddEdge(mst, fromUid, pickUid, MakeEdgeInfo(fromUid, pickUid, allyNodes)) + + -- Update bestEdge for any pending node whose nearest tree member + -- might now be the just-added pickUid. + ForEachCandidate(pickUid, allyNodes, cells, function(j, distSq) + if pending[j] then + local cur = bestEdge[j] + if not cur or distSq < cur.distSq then + bestEdge[j] = { distSq = distSq, fromUid = pickUid } + end + end + end) + end + end +end + +-- Remove a batch of nodes from the MST. Cut all incident edges, then +-- identify the components left behind by BFS over `mst.adj` (seeded by +-- the surviving neighbours of the removed nodes). Reconnect components +-- pairwise by cheapest cross-edge until all collapse back into one. +local function MstRemoveNodes(mst, removeUids, allyNodes, cells) + -- Snapshot the set of removed uids and their surviving neighbours. + local removedSet = {} + local seeds = {} + for i = 1, #removeUids do + local uid = removeUids[i] + if mst.members[uid] then + removedSet[uid] = true + local nb = mst.adj[uid] + if nb then + for n in pairs(nb) do seeds[n] = true end + end + end + end + if not next(removedSet) then return end + -- A removed node's neighbours might also be in removedSet; filter them. + for uid in pairs(removedSet) do seeds[uid] = nil end + + -- Cut all edges incident to any removed uid, then drop the removed members. + for uid in pairs(removedSet) do + local nb = mst.adj[uid] + if nb then + local toCut = {} + for n in pairs(nb) do toCut[#toCut + 1] = n end + for k = 1, #toCut do + MstRemoveEdge(mst, uid, toCut[k]) + end + end + mst.members[uid] = nil + end + + if not next(seeds) then return end -- nothing to reconnect (all removed leaves) + + -- Identify components remaining in the cut graph by BFS over mst.adj + -- seeded at each surviving neighbour of a removed node. + local componentOf = {} + local components = {} -- [seedUid] = { [memberUid] = true } + local compIds = {} + for seed in pairs(seeds) do + if not componentOf[seed] then + local mem = { [seed] = true } + componentOf[seed] = seed + local stack = { seed } + while #stack > 0 do + local u = stack[#stack]; stack[#stack] = nil + local nb = mst.adj[u] + if nb then + for n in pairs(nb) do + if not mem[n] then + mem[n] = true + componentOf[n] = seed + stack[#stack + 1] = n + end + end + end + end + components[seed] = mem + compIds[#compIds + 1] = seed + end + end + + if #compIds <= 1 then return end -- all neighbours converged into one component + + -- Borůvka reconnect: find the globally cheapest cross-edge between any + -- two components, add it, merge, repeat until one component remains. + while #compIds > 1 do + local bestDistSq = math.huge + local bestFrom, bestTo, bestFromComp, bestToComp = nil, nil, nil, nil + for ci = 1, #compIds do + local cid = compIds[ci] + local mem = components[cid] + for uid in pairs(mem) do + ForEachCandidate(uid, allyNodes, cells, function(j, distSq) + local jcid = componentOf[j] + if jcid and jcid ~= cid and distSq < bestDistSq then + bestDistSq = distSq + bestFrom, bestTo = uid, j + bestFromComp, bestToComp = cid, jcid + end + end) + end + end + if not bestFrom then break end -- truly disconnected (engine should split gridID first) + + MstAddEdge(mst, bestFrom, bestTo, MakeEdgeInfo(bestFrom, bestTo, allyNodes)) + + -- Merge components: union toComp into fromComp. + local fc = components[bestFromComp] + local tc = components[bestToComp] + for u in pairs(tc) do + fc[u] = true + componentOf[u] = bestFromComp + end + components[bestToComp] = nil + for i = 1, #compIds do + if compIds[i] == bestToComp then + table.remove(compIds, i) + break + end + end + end +end + +-- Mint a fresh, empty MST record for a grid. +local function MakeEmptyMst(allyTeamID, gridID) + return { + ally = allyTeamID, gridID = gridID, + members = {}, edges = {}, adj = {}, + } +end + +-- From-scratch build for grids with no cached MST. Uses inline Prim's over +-- a per-grid spatial hash (the same algorithm as the original BuildGridMST +-- before incremental was introduced); fast at large N because the inner +-- loops avoid closures and table lookups stay tight. MstAddNodes is reserved +-- for SMALL incremental add batches into an existing tree where the closure +-- overhead is negligible relative to the savings vs full rebuild. +local function BuildGridMSTFromScratch(allyTeamID, gridID, allyNodes, allyCells) + local perf = cablePerf + local tStart = perf and Spring.GetTimer() + local mst = MakeEmptyMst(allyTeamID, gridID) + if not allyNodes then return mst end + + -- Collect this grid's pylons + per-pylon position+range in arrays. + local px, pz, prange, puid = {}, {}, {}, {} + for uid, node in pairs(allyNodes) do + if lastGridNum[uid] == gridID then + local idx = #puid + 1 + puid[idx] = uid + px[idx] = node.x + pz[idx] = node.z + prange[idx] = node.range + end + end + local n = #puid + if n == 0 then return mst end + -- Single-member grid: just register the lone pylon, no edges. + if n == 1 then mst.members[puid[1]] = true; return mst end + + -- (No per-grid spatial hash needed — pylonNeighbours is the precomputed + -- bidirectional global neighbour index, maintained on UnitCreated.) + local tHash = perf and Spring.GetTimer() + + -- Per-pylon neighbour list (indices into px/pz/etc) sourced from the + -- global pylonNeighbours cache, filtered down to pylons in this grid. + -- Replaces an O(N × cellsize) spatial-hash scan with O(sum of degrees). + local uidToIdx = {} + for i = 1, n do uidToIdx[puid[i]] = i end + local neighbors = {} + for i = 1, n do + local nlist = {} + local nb = pylonNeighbours[puid[i]] + if nb then + for nuid in pairs(nb) do + local idx = uidToIdx[nuid] + if idx then nlist[#nlist + 1] = idx end + end + end + neighbors[i] = nlist + end + local tNeigh = perf and Spring.GetTimer() + + -- Pick highest-Pmax pylon as the seed (stable across wind/load). + local bestRoot = 1 + local bestProd = -1 + for i = 1, n do + local prod = GetNodePmax(allyNodes[puid[i]].unitDefID) + if prod > bestProd then bestProd = prod; bestRoot = i end + end + + -- Prim with a binary min-heap on the frontier. The previous version did + -- a linear scan over `bestEdge` per pick → O(N²) overall, which dominated + -- runtime once N ≳ 1000. The heap pushes each frontier-update in O(log N) + -- and the pick is O(log N), making the whole MST construction O(E log V). + -- We use lazy invalidation: when we update a node's bestEdge to a cheaper + -- distance, we just push a new heap entry; older entries get skipped on + -- pop because they no longer match `bestEdge[pickJ].distSq`. + -- Heap is a flat array: entries are integer-packed `distSq * MAX_N + idx` + -- to avoid per-entry table allocation. (Lua sin sin sin: math, not tables.) + local inTree = { [bestRoot] = true } + mst.members[puid[bestRoot]] = true + local treeSize = 1 + local bestEdge = {} -- [idx] = { distSq, fromIdx } + local heapD = {} -- distSq values; heap[1..#] is the heap + local heapI = {} -- frontier idx, parallel array to heapD + local heapN = 0 + + local function heapPush(d, i) + heapN = heapN + 1 + heapD[heapN] = d + heapI[heapN] = i + local ci = heapN + while ci > 1 do + local p = floor(ci / 2) + if heapD[p] > heapD[ci] then + heapD[ci], heapD[p] = heapD[p], heapD[ci] + heapI[ci], heapI[p] = heapI[p], heapI[ci] + ci = p + else + break + end + end + end + + local function heapPop() + if heapN == 0 then return nil, nil end + local d, i = heapD[1], heapI[1] + heapD[1], heapI[1] = heapD[heapN], heapI[heapN] + heapD[heapN], heapI[heapN] = nil, nil + heapN = heapN - 1 + local ci = 1 + while true do + local l = ci * 2 + local r = l + 1 + local s = ci + if l <= heapN and heapD[l] < heapD[s] then s = l end + if r <= heapN and heapD[r] < heapD[s] then s = r end + if s == ci then break end + heapD[ci], heapD[s] = heapD[s], heapD[ci] + heapI[ci], heapI[s] = heapI[s], heapI[ci] + ci = s + end + return d, i + end + + do + local pxi, pzi = px[bestRoot], pz[bestRoot] + for _, j in ipairs(neighbors[bestRoot]) do + local dx = pxi - px[j] + local dz = pzi - pz[j] + local distSq = dx * dx + dz * dz + bestEdge[j] = { distSq = distSq, from = bestRoot } + heapPush(distSq, j) + end + end + + while treeSize < n do + -- Pop cheapest; skip stale entries (already in tree, or superseded + -- by a cheaper bestEdge update since this entry was pushed). + local pickD, pickJ + while true do + pickD, pickJ = heapPop() + if not pickJ then break end + local be = bestEdge[pickJ] + if be and not inTree[pickJ] and be.distSq == pickD then break end + end + if not pickJ then break end + inTree[pickJ] = true + treeSize = treeSize + 1 + local fromIdx = bestEdge[pickJ].from + bestEdge[pickJ] = nil + + local fromUid, toUid = puid[fromIdx], puid[pickJ] + mst.members[toUid] = true + MstAddEdge(mst, fromUid, toUid, { + parentID = fromUid, childID = toUid, + px = px[fromIdx], pz = pz[fromIdx], + cx = px[pickJ], cz = pz[pickJ], + }) + + local pxj, pzj = px[pickJ], pz[pickJ] + for _, k in ipairs(neighbors[pickJ]) do + if not inTree[k] then + local dx = pxj - px[k] + local dz = pzj - pz[k] + local distSq = dx * dx + dz * dz + local cur = bestEdge[k] + if not cur or distSq < cur.distSq then + bestEdge[k] = { distSq = distSq, from = pickJ } + -- Push the new (cheaper) entry; the old heap slot for k + -- will be skipped on pop because its stored distSq won't + -- match bestEdge[k].distSq anymore (lazy invalidation). + heapPush(distSq, k) + end + end + end + end + + if perf then + local tEnd = Spring.GetTimer() + local totalMs = Spring.DiffTimers(tEnd, tStart) * 1000 + if totalMs > perfStats.worstRebuildMs then + perfStats.worstRebuildMs = totalMs + perfStats.worstRebuildN = n + perfStats.worstRebuildHashMs = Spring.DiffTimers(tHash, tStart) * 1000 + perfStats.worstRebuildNeighMs = Spring.DiffTimers(tNeigh, tHash) * 1000 + perfStats.worstRebuildPrimMs = Spring.DiffTimers(tEnd, tNeigh) * 1000 + end + end + + return mst +end + +------------------------------------------------------------------------------------- +-- Grid sync: snapshot every pylon's current gridNumber, rebuild every grid in +-- the snapshot, diff resulting edge set against `edges` so survivors keep +-- their stable identity (and unsynced animation state) while drops/adds flip +-- topologyDirty. Stateless w.r.t. previous gridIDs — robust against gridID +-- reuse, merges, splits, and rules-param resets we can't observe. +------------------------------------------------------------------------------------- + +local function SyncWithGrid() + local perf = cablePerf + local t0 = perf and Spring.GetTimer() + + -- 1) Drop dead units; mark their last-known grid as losing the dying uid. + for allyTeamID, allyNodes in pairs(nodes) do + local toRemove + for unitID, _ in pairs(allyNodes) do + if not spValidUnitID(unitID) then + toRemove = toRemove or {} + toRemove[#toRemove + 1] = unitID + end + end + if toRemove then + for i = 1, #toRemove do + local uid = toRemove[i] + local node = allyNodes[uid] + if node then + PylonRemoveSpatial(allyTeamID, uid, node.x, node.z) + end + PylonClearNeighbours(uid) + MarkGridRemove(allyTeamID, lastGridNum[uid], uid) + allyNodes[uid] = nil + lastGridNum[uid] = nil + allyOfUnit[uid] = nil + nodeDefByUID[uid] = nil + consumerNodeIndex[uid] = nil + lastConsumerDcur[uid] = nil + minWindByUID[uid] = nil + end + end + end + local t1 = perf and Spring.GetTimer() + + -- 2) Refresh lastGridNum from rules-params and detect membership changes. + -- Any pylon whose effective gridID flipped is "removed" from the old + -- grid AND "added" to the new grid (gridID 0 = inactive, no-op). + -- Track each migrating uid's source gridID so step 2.5 can transfer + -- the cached MST when a grid is just being renumbered (engine + -- reassigns gridIDs on topology shifts → 1000s of pylons going + -- gridA→gridB in one tick → without rename detection we'd full- + -- rebuild gridB from scratch). + local unitFromGrid = {} -- [uid] = oldG (only for uids that flipped to a non-zero newG) + for allyTeamID, allyNodes in pairs(nodes) do + for unitID, _ in pairs(allyNodes) do + local newG = (IsActiveForGrid(unitID) and (spGetUnitRulesParam(unitID, "gridNumber") or 0)) or 0 + local oldG = lastGridNum[unitID] + if oldG ~= newG then + if oldG and oldG > 0 then MarkGridRemove(allyTeamID, oldG, unitID) end + if newG > 0 then MarkGridAdd(allyTeamID, newG, unitID) end + lastGridNum[unitID] = newG + if oldG and oldG > 0 and newG > 0 then + unitFromGrid[unitID] = oldG + end + end + end + end + + -- 2.5) MST transfer when a new grid's add-set substantially overlaps an + -- existing cached MST's members (i.e. the engine just renumbered + -- most of the grid). Transfer the cache under the new key, then + -- derive minimal "effective" remove/add lists so the post-transfer + -- tree converges on the actual new membership in O(diff) instead + -- of O(N). + local renames = 0 + for newGk, newInfo in pairs(pendingGridDirty) do + if not mstByGrid[newGk] and #newInfo.adds > 0 then + -- Look up source grid via the migration record of any add. + local sampleUid = newInfo.adds[1] + local sourceOldG = unitFromGrid[sampleUid] + if sourceOldG then + local oldGk = GridKey(newInfo.ally, sourceOldG) + local oldMst = mstByGrid[oldGk] + if oldMst then + -- Count overlap between new adds and old MST members. + local addsSet = {} + for _, u in ipairs(newInfo.adds) do addsSet[u] = true end + local oldMemCount = 0 + local kept = 0 + for u in pairs(oldMst.members) do + oldMemCount = oldMemCount + 1 + if addsSet[u] then kept = kept + 1 end + end + -- Worth transferring only if this is a near-rename (≥75% + -- retained AND ≤200 cleanup removes). For split scenarios + -- (e.g. grid cut in half) the cleanup-remove cost on the + -- larger side dominates; full Prim from scratch on each + -- piece is cheaper than transfer-then-prune-half. + local needRemove = oldMemCount - kept + if oldMemCount > 0 and kept * 4 >= oldMemCount * 3 and needRemove <= 200 then + oldMst.gridID = newInfo.gridID + mstByGrid[newGk] = oldMst + mstByGrid[oldGk] = nil + -- Build effective remove/add lists relative to the + -- transferred MST's current members: + -- remove = oldMembers \ adds (dead or migrated elsewhere) + -- add = adds \ oldMembers (genuinely new pylons) + local effRemoves = {} + for u in pairs(oldMst.members) do + if not addsSet[u] then + effRemoves[#effRemoves + 1] = u + end + end + local effAdds = {} + for _, u in ipairs(newInfo.adds) do + if not oldMst.members[u] then + effAdds[#effAdds + 1] = u + end + end + newInfo.removes = effRemoves + newInfo.adds = effAdds + -- Old grid's pending entry is now redundant: its + -- removes are either covered by effRemoves above + -- (if they're still in oldMst.members) or never + -- existed in the MST in the first place. + pendingGridDirty[oldGk] = nil + renames = renames + 1 + end + end + end + end + end + local t2 = perf and Spring.GetTimer() + + -- 3) Apply per-grid changes. Strategy gating: + -- a) New grid (no cached MST) → full rebuild via fast Prim's. + -- b) Big cached MST → full rebuild on any change. The Borůvka + -- reconnect inside MstRemoveNodes scans every member of every + -- cut-component looking for cheapest cross-edges; on large dense + -- grids that's O(N²) and can blow up to seconds. Full Prim is + -- O(N log N) with much tighter inner loops, so above the size + -- threshold rebuild is cheaper than incremental. + -- c) Small cached MST + small diff → incremental. + -- d) Cached MST + big diff (>50 changes) → also rebuild. + local INCR_GRID_SIZE_LIMIT = 200 -- skip incremental on grids bigger than this + local INCR_DIFF_LIMIT = 50 -- skip incremental on diffs bigger than this + local rebuilds = 0 + local incrementals = 0 + local allyHashCache = {} -- [allyTeamID] = { cells, allyNodes } + local function getAllyHash(allyTeamID) + local h = allyHashCache[allyTeamID] + if not h then + local cells, allyNodesRef = BuildAllySpatialHash(allyTeamID) + h = { cells = cells, allyNodes = allyNodesRef } + allyHashCache[allyTeamID] = h + end + return h.cells, h.allyNodes + end + for gk, info in pairs(pendingGridDirty) do + local cells, allyNodesRef = getAllyHash(info.ally) + local mst = mstByGrid[gk] + local memCount = 0 + if mst then + for _ in pairs(mst.members) do memCount = memCount + 1 end + end + local rebuildFromScratch = (not mst) + or memCount > INCR_GRID_SIZE_LIMIT + or #info.removes > INCR_DIFF_LIMIT + or #info.adds > INCR_DIFF_LIMIT + if rebuildFromScratch then + mst = BuildGridMSTFromScratch(info.ally, info.gridID, allyNodesRef, cells) + rebuilds = rebuilds + 1 + else + if #info.removes > 0 then + MstRemoveNodes(mst, info.removes, allyNodesRef, cells) + end + if #info.adds > 0 then + MstAddNodes(mst, info.adds, allyNodesRef, cells) + end + incrementals = incrementals + 1 + end + if next(mst.members) then + mstByGrid[gk] = mst + else + mstByGrid[gk] = nil + end + pendingGridDirty[gk] = nil + end + local t3 = perf and Spring.GetTimer() + + -- 4) Compose the desired edge set from cached MSTs. + local newEdges = {} + for _, mst in pairs(mstByGrid) do + for ek, einfo in pairs(mst.edges) do + newEdges[ek] = einfo + end + end + local t4 = perf and Spring.GetTimer() + + -- 5) Diff: drop missing, add new. Survivors keep their entry (and + -- ComputeMaxPotentials reorientation) untouched. Topology change here + -- invalidates the mpCache so its DFS / aggregates get rebuilt next call. + for ek, _ in pairs(edges) do + if not newEdges[ek] then + edges[ek] = nil + topologyDirty = true + mpCache.valid = false + end + end + for ek, einfo in pairs(newEdges) do + if not edges[ek] then + edges[ek] = { + parentID = einfo.parentID, childID = einfo.childID, + px = einfo.px, pz = einfo.pz, cx = einfo.cx, cz = einfo.cz, + } + topologyDirty = true + mpCache.valid = false + end + end + if perf then + local t5 = Spring.GetTimer() + perfStats.dropMs = Spring.DiffTimers(t1, t0) * 1000 + perfStats.refreshMs = Spring.DiffTimers(t2, t1) * 1000 + perfStats.mstMs = Spring.DiffTimers(t3, t2) * 1000 + perfStats.mstRebuilds = rebuilds + perfStats.mstIncrements = incrementals + perfStats.composeMs = Spring.DiffTimers(t4, t3) * 1000 + perfStats.diffMs = Spring.DiffTimers(t5, t4) * 1000 + end +end + +------------------------------------------------------------------------------------- +-- Max-potential per edge: max flow that could ever cross the cable, given the +-- nameplate production and static draw (mex = ∞, voltage units = neededlink) +-- on each side of the cut. Two passes per tree: +-- 1. Post-order DFS aggregates subtreePmax / subtreeDmax per child edge. +-- 2. Per edge, otherSide = total − subtreeSide; capacity is symmetric: +-- max( min(sP, oDmax), min(oP, sDmax) ) +-- With ∞ mex draw this collapses: when both sides have a mex, capacity becomes +-- max(sP, oP) (= the larger producer half feeds the smaller). When only one +-- side has a mex, capacity = the producer-side Pmax. Voltage-only cuts use +-- the (finite) sum of neededlink thresholds. +------------------------------------------------------------------------------------- + +-- Build the topology-stable mpCache: adjacency, DFS order, parentInTree, +-- per-component root, and *static* per-subtree aggregates. +-- +-- Static aggregates per subtree (recomputed only on topology change): +-- subPmax Σ nameplate production (windmill counts as windMax) +-- subDmax Σ nameplate draw (mexes = INF_DRAW, voltage units = neededlink) +-- subPmaxNonWind Σ nameplate production over non-wind generators only. +-- Wind contribution to Pcur is computed per tick from +-- subWindCount + subWindBase via the aggregate formula. +-- subWindCount number of windmills in the subtree +-- subWindBase Σ minWind (per-windmill rules-param, in absolute E/s). +-- Combined with current wind strength to produce subPcur: +-- windPcur = subWindBase + (curr/windMax) * +-- (windMax * subWindCount - subWindBase) +-- This eliminates per-windmill rules-param reads on the hot +-- path: one Spring.GetWind() suffices for the whole tick. +local function BuildMpCache() + local adj = {} + local nodeSet = {} + for key, edge in pairs(edges) do + local a, b = edge.parentID, edge.childID + adj[a] = adj[a] or {}; adj[a][#adj[a] + 1] = { neigh = b, key = key } + adj[b] = adj[b] or {}; adj[b][#adj[b] + 1] = { neigh = a, key = key } + nodeSet[a] = true + nodeSet[b] = true + end + + local parentInTree = {} + local order = {} + local visited = {} + + local function dfsRoot(rootID) + visited[rootID] = true + order[#order + 1] = rootID + local stack = { rootID } + while #stack > 0 do + local u = stack[#stack]; stack[#stack] = nil + local ns = adj[u] + if ns then + for i = 1, #ns do + local nb, ek = ns[i].neigh, ns[i].key + if not visited[nb] then + visited[nb] = true + parentInTree[nb] = { parent = u, key = ek } + order[#order + 1] = nb + stack[#stack + 1] = nb + end + end + end + end + end + + for uid in pairs(nodeSet) do + if not visited[uid] then + local componentNodes = {} + local stk = { uid } + local seen = { [uid] = true } + while #stk > 0 do + local v = stk[#stk]; stk[#stk] = nil + componentNodes[#componentNodes + 1] = v + local ns = adj[v] + if ns then + for i = 1, #ns do + local nb = ns[i].neigh + if not seen[nb] then seen[nb] = true; stk[#stk + 1] = nb end + end + end + end + local bestID, bestP = uid, -1 + for i = 1, #componentNodes do + local v = componentNodes[i] + local did = nodeDefByUID[v] + local p = did and GetNodePmax(did) or 0 + if p > bestP then bestP = p; bestID = v end + end + dfsRoot(bestID) + end + end + + -- componentRoot is computable in one forward pass over `order` because + -- each parent appears earlier than its child in DFS order. + local componentRoot = {} + for i = 1, #order do + local u = order[i] + local pi = parentInTree[u] + if not pi then + componentRoot[u] = u + else + componentRoot[u] = componentRoot[pi.parent] + end + end + + -- Static per-subtree aggregates (post-order over `order`). + local subPmax = {} + local subDmax = {} + local subPmaxNonWind = {} + local subWindCount = {} + local subWindBase = {} + for i = 1, #order do + local u = order[i] + local did = nodeDefByUID[u] + subPmax[u] = did and GetNodePmax(did) or 0 + subDmax[u] = did and GetNodeDmax(did) or 0 + if did and isWindgenByDef[did] then + subWindCount[u] = 1 + subWindBase[u] = GetCachedMinWind(u) + subPmaxNonWind[u] = 0 + else + subWindCount[u] = 0 + subWindBase[u] = 0 + subPmaxNonWind[u] = (did and pmaxByDef[did]) or 0 + end + end + for i = #order, 1, -1 do + local u = order[i] + local pi = parentInTree[u] + if pi then + local p = pi.parent + subPmax[p] = subPmax[p] + subPmax[u] + subDmax[p] = subDmax[p] + subDmax[u] + subPmaxNonWind[p] = subPmaxNonWind[p] + subPmaxNonWind[u] + subWindCount[p] = subWindCount[p] + subWindCount[u] + subWindBase[p] = subWindBase[p] + subWindBase[u] + end + end + + mpCache.adj = adj + mpCache.parentInTree = parentInTree + mpCache.order = order + mpCache.componentRoot = componentRoot + mpCache.subPmax = subPmax + mpCache.subDmax = subDmax + mpCache.subPmaxNonWind = subPmaxNonWind + mpCache.subWindCount = subWindCount + mpCache.subWindBase = subWindBase + mpCache.valid = true +end + +-- `flowMode = false` skips the per-tick consumer rules-param reads and the +-- post-order subDcur accumulation, returning all flows = 0. At 1500+ pylons +-- this is the bulk of the per-tick cost (the only path that scales with the +-- consumer set size). Capacities still come from the static cache, and edge +-- reorientation falls back to capacity direction so the layout stays stable. +local function ComputeMaxPotentials(flowMode) + local perf = cablePerf + local tBuild0 = perf and Spring.GetTimer() + if not mpCache.valid then BuildMpCache() end + local tBuild1 = perf and Spring.GetTimer() + local order = mpCache.order + local parentInTree = mpCache.parentInTree + local componentRoot = mpCache.componentRoot + local subPmax = mpCache.subPmax + local subDmax = mpCache.subDmax + + local subPcur, subDcur + if flowMode then + local subPmaxNonWind = mpCache.subPmaxNonWind + local subWindCount = mpCache.subWindCount + local subWindBase = mpCache.subWindBase + + -- ZK's per-windmill formula (unit_windmill_control.lua:142): + -- windEnergy_i = (windMax − curr_strength) * myMin_i + curr_strength + -- This is linear in curr_strength, so the subtree sum is also linear: + -- Σ windE = subWindBase * (1 − f) + windMax * f * subWindCount + -- where f = curr_strength / windMax = WindStrength rules-param ∈ [0,1]. + -- + -- IMPORTANT: ZK's `strength` and Spring.GetWind() are different. The + -- engine-side GetWind() is NOT capped to windMax (returns ~27 on a + -- map whose windMax=2.5) — using it forces windFrac to clamp to 1 + -- every tick, attributing windMax × N to wind output (~2× truth). + -- The authoritative ZK value is GameRulesParam("WindStrength"). + local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 + local windFrac = GetCurWindFrac() + + -- subPcur from cached aggregates: 0 per-pylon reads. + subPcur = {} + for i = 1, #order do + local u = order[i] + subPcur[u] = subWindBase[u] + windFrac * (windMax * subWindCount[u] - subWindBase[u]) + + subPmaxNonWind[u] + end + + -- Consumer reads still per-tick (mex draw / builder energyUse fluctuate). + subDcur = {} + for i = 1, #order do + local u = order[i] + local did = nodeDefByUID[u] + subDcur[u] = (did and consumerByDef[did]) and GetNodeDcurrent(u, did) or 0 + end + for i = #order, 1, -1 do + local u = order[i] + local pi = parentInTree[u] + if pi then + subDcur[pi.parent] = subDcur[pi.parent] + subDcur[u] + end + end + end + + -- Per-edge min-cut math + reorientation. Component root is precomputed + -- so we don't walk parent chains per edge. + local capacities = {} + local flows = {} + local debugLog = DEBUG_FLOW and {} or nil + local function fmtD(v) return v >= INF_DRAW * 0.5 and "INF" or string.format("%.0f", v) end + + for cid, info in pairs(parentInTree) do + local key = info.key + local pid = info.parent + local r = componentRoot[cid] + + local totalP, totalD = subPmax[r], subDmax[r] + local sP, sD = subPmax[cid], subDmax[cid] + local oP, oD = totalP - sP, totalD - sD + local capAB = (sP < oD) and sP or oD + local capBA = (oP < sD) and oP or sD + local cap = (capAB > capBA) and capAB or capBA + capacities[key] = cap + local potentialSrcSubtree = capAB > capBA + + local flow, flowSrcSubtree + if flowMode then + local totalPcur, totalDcur = subPcur[r], subDcur[r] + local sPc, sDc = subPcur[cid], subDcur[cid] + local oPc, oDc = totalPcur - sPc, totalDcur - sDc + local flowAB = (sPc < oDc) and sPc or oDc + local flowBA = (oPc < sDc) and oPc or sDc + if flowAB >= flowBA then + flow, flowSrcSubtree = flowAB, true + else + flow, flowSrcSubtree = flowBA, false + end + if flow < 0 then flow = 0 end + if flow <= 0 then flowSrcSubtree = potentialSrcSubtree end + else + flow, flowSrcSubtree = 0, potentialSrcSubtree + end + flows[key] = flow + + local edge = edges[key] + if edge then + local newParent = flowSrcSubtree and cid or pid + local newChild = flowSrcSubtree and pid or cid + if edge.parentID ~= newParent then + local pAlly = allyOfUnit[newParent] + local cAlly = allyOfUnit[newChild] + local np = pAlly and nodes[pAlly] and nodes[pAlly][newParent] + local nc = cAlly and nodes[cAlly] and nodes[cAlly][newChild] + if np and nc then + edge.parentID, edge.childID = newParent, newChild + edge.px, edge.pz = np.x, np.z + edge.cx, edge.cz = nc.x, nc.z + end + end + end + + if debugLog then + local e = edges[key] + local pname = nodeDefByUID[e.parentID] and UnitDefs[nodeDefByUID[e.parentID]].name or tostring(e.parentID) + local cname = nodeDefByUID[e.childID] and UnitDefs[nodeDefByUID[e.childID]].name or tostring(e.childID) + debugLog[#debugLog + 1] = string.format( + " %-13s -> %-13s sP=%-7.1f sD=%-5s oP=%-7.1f oD=%-5s cap=%.1f flow=%.1f", + pname, cname, sP, fmtD(sD), oP, fmtD(oD), cap, flow) + end + end + + if debugLog and #debugLog > 0 then + Spring.Echo("[OD-cables] capacities:") + for i = 1, #debugLog do Spring.Echo(debugLog[i]) end + end + + if perf then + local tEnd = Spring.GetTimer() + perfStats.mpBuildMs = Spring.DiffTimers(tBuild1, tBuild0) * 1000 + perfStats.mpComputeMs = Spring.DiffTimers(tEnd, tBuild1) * 1000 + end + + return capacities, flows +end + +------------------------------------------------------------------------------------- +-- Hand a Full snapshot to the rendering side. One snapshot per ally; the +-- per-ally batching is preserved because OnCableTreeFull's diff is per-ally. +-- Capacity drift between topology changes is ignored (acceptable: cable +-- colour only updates when the grid actually mutates). +------------------------------------------------------------------------------------- + +-- Returns true if newFlow differs enough from oldFlow to warrant a re-send. +-- Loose absolute floor + a relative threshold above it: small absolute flows +-- are noisy (mex draw fluctuates by ±0.x E/s as targets move), while large +-- ones need relative tolerance because bubble speed ∝ sqrt(flow). +local function flowChanged(newFlow, oldFlow) + local d = newFlow - oldFlow + if d < 0 then d = -d end + if d <= FLOW_ABS_EPSILON then return false end + local base = oldFlow + if base < 0 then base = -base end + if base < FLOW_ABS_EPSILON then return true end -- big abs change off ~zero + return (d / base) > FLOW_REL_EPSILON +end + +-- Cheap pre-check that runs BEFORE ComputeMaxPotentials. The mp.compute +-- pass is O(N) over every pylon (~4ms at 4000 nodes) — running it just to +-- discover "nothing changed" is wasted work. Instead, sample only the +-- consumer-typed nodes (typically ~50 of 4000) plus the wind state; if +-- nothing has shifted, skip mp + bin + dispatch entirely. The bubble shader +-- keeps extrapolating from its last bake at the last-sent speed, which is +-- the correct visual when flows haven't changed. +local function ConsumersOrWindChanged() + for uid, did in pairs(consumerNodeIndex) do + local cur = GetNodeDcurrent(uid, did) + local last = lastConsumerDcur[uid] + if not last or math.abs(cur - last) > 0.5 then + return true + end + end + if math.abs(GetCurWindFrac() - lastWindFrac) > 0.05 then return true end + return false +end + +local function SendAll() + local perf = cablePerf + + -- O(consumers) early-skip BEFORE the expensive O(N) ComputeMaxPotentials. + -- Topology changes always force through; FORCE_SEND_TICKS clamps drift. + ticksSinceSend = ticksSinceSend + 1 + if not topologyDirty and ticksSinceSend < FORCE_SEND_TICKS then + if not ConsumersOrWindChanged() then + if perf then perfStats.skipped = (perfStats.skipped or 0) + 1 end + return false + end + end + + local capacities, flows = ComputeMaxPotentials(cableFlowMode) + + -- Belt-and-suspenders flow-comparison: even after consumer/wind changed, + -- the resulting flows may still be within tolerance (binding constraint + -- elsewhere). Skip if no edge's flow changed visibly. + if not topologyDirty and ticksSinceSend < FORCE_SEND_TICKS then + local anyChanged = false + for key, _ in pairs(edges) do + local newFlow = flows[key] or 0 + local oldFlow = lastSentFlow[key] + if oldFlow == nil or flowChanged(newFlow, oldFlow) then + anyChanged = true + break + end + end + if not anyChanged then + if perf then perfStats.skipped = (perfStats.skipped or 0) + 1 end + return false + end + end + ticksSinceSend = 0 + + local tBin0 = perf and Spring.GetTimer() + + -- Per-grid efficiency cache. gridefficiency is uniform across a whole + -- grid (set on every member by unit_mex_overdrive), so reading it per + -- edge does ~2*E rules-param reads where ~G (G = number of distinct + -- grids, typically <10) suffices. At 460+ edges this turns ~900 reads + -- into ~5. lastGridNum is the SyncWithGrid-maintained gridID per pylon. + local effByGrid = {} + local function gridEffForUnit(uid) + local gid = lastGridNum[uid] + if not gid or gid == 0 then return nil end + local cached = effByGrid[gid] + if cached ~= nil then return cached end + local eff = spGetUnitRulesParam(uid, "gridefficiency") + if eff and eff < 0 then eff = 0 end + effByGrid[gid] = eff or false -- `false` distinguishes "tried, nil" from "uncached" + return eff + end + + -- Bin edges by ally, in one pass. + local perAlly = {} + for key, edge in pairs(edges) do + local ally = allyOfUnit[edge.parentID] or allyOfUnit[edge.childID] + if ally then + local pa = perAlly[ally] + if not pa then + pa = { + keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, + caps = {}, flows = {}, effs = {}, n = 0, + } + perAlly[ally] = pa + end + pa.n = pa.n + 1 + local i = pa.n + pa.keys[i] = key + pa.pxs[i], pa.pzs[i] = edge.px, edge.pz + pa.cxs[i], pa.czs[i] = edge.cx, edge.cz + pa.caps[i] = capacities[key] or 0 + pa.flows[i] = flows[key] or 0 + -- Cached per-grid lookup; fall back to child end if parent's grid + -- is unknown. 0 → magenta in the shader (unit_mex_overdrive's + -- "no grid" sentinel). + local eff = gridEffForUnit(edge.parentID) or gridEffForUnit(edge.childID) or 0 + pa.effs[i] = eff + -- Snapshot for the next tick's stability check. + lastSentFlow[key] = pa.flows[i] + lastSentEff[key] = eff + end + end + local tBin1 = perf and Spring.GetTimer() + + -- One snapshot per ally that currently has edges. + for ally, pa in pairs(perAlly) do + OnCableTreeFull({ + allyTeamID = ally, edgeCount = pa.n, + keys = pa.keys, pxs = pa.pxs, pzs = pa.pzs, + cxs = pa.cxs, czs = pa.czs, + caps = pa.caps, flows = pa.flows, effs = pa.effs, + }) + alliesWithEdges[ally] = true + end + + -- Allies whose last edge just disappeared get one zero-edge snapshot so + -- the renderer clears them; then we forget them. + for ally in pairs(alliesWithEdges) do + if not perAlly[ally] then + OnCableTreeFull({ + allyTeamID = ally, edgeCount = 0, + keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, + caps = {}, flows = {}, effs = {}, + }) + alliesWithEdges[ally] = nil + end + end + -- Update the snapshots ConsumersOrWindChanged() compares against next + -- tick. Doing this only on the success path means a skipped tick keeps + -- the previous baseline so a stable run continues to skip. + for uid, did in pairs(consumerNodeIndex) do + lastConsumerDcur[uid] = GetNodeDcurrent(uid, did) + end + lastWindFrac = GetCurWindFrac() + + if perf then + local tEnd = Spring.GetTimer() + perfStats.binMs = Spring.DiffTimers(tBin1, tBin0) * 1000 + perfStats.dispatchMs = Spring.DiffTimers(tEnd, tBin1) * 1000 + end +end + +------------------------------------------------------------------------------------- +-- GameFrame +------------------------------------------------------------------------------------- + +-- Sends one zero-edge snapshot per ally that currently has cables, so the +-- renderer clears its geometry. Used when the visualization is toggled off +-- so no stale cables linger. +local function ClearAll() + for ally in pairs(alliesWithEdges) do + OnCableTreeFull({ + allyTeamID = ally, edgeCount = 0, + keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, + caps = {}, flows = {}, effs = {}, + }) + end + alliesWithEdges = {} + edges = {} + topologyDirty = false + -- Reset stability snapshots; on next enable, all edges read as new. + lastSentFlow = {} + lastSentEff = {} + ticksSinceSend = 0 +end + +-- Periodic topology refresh + send. Driven by gadget:GameFrame on the +-- SYNC_PERIOD cadence so the cost is bounded regardless of how often pylons +-- move/build. +local function RunSyncTick(n) + if not cableEnabled then return end + if n % SYNC_PERIOD == 2 then + local perf = cablePerf + local tStart = perf and Spring.GetTimer() + SyncWithGrid() + -- Flow mode: always send (flow magnitudes + grid efficiency colour + -- change every tick). No-flow mode: only send on topology change — + -- there's no per-tick state to refresh, and the per-tick send cost + -- (capacity-only ComputeMaxPotentials + per-ally upload) is the + -- entire point of the toggle. + local sentThisTick = false + if cableFlowMode or topologyDirty then + -- SendAll returns false when its stability check short-circuited. + sentThisTick = (SendAll() ~= false) + topologyDirty = false + end + if perf then + local tEnd = Spring.GetTimer() + local nEdges = 0 + for _ in pairs(edges) do nEdges = nEdges + 1 end + local nNodes = 0 + for _, allyNodes in pairs(nodes) do + for _ in pairs(allyNodes) do nNodes = nNodes + 1 end + end + -- Total wallclock for this tick (sync + send). + local totalMs = Spring.DiffTimers(tEnd, tStart) * 1000 + local rebuildLine = "" + if perfStats.worstRebuildMs > 0 then + rebuildLine = string.format( + " | worstRebuild=%dms[N=%d hash=%.1f neigh=%.1f prim=%.1f]", + perfStats.worstRebuildMs, perfStats.worstRebuildN, + perfStats.worstRebuildHashMs, perfStats.worstRebuildNeighMs, + perfStats.worstRebuildPrimMs) + end + Spring.Echo(string.format( + "[CableTree] tick: nodes=%d edges=%d total=%.2fms | " .. + "sync(drop=%.2f refresh=%.2f mst=%.2f[rebuild=%d incr=%d] compose=%.2f diff=%.2f) | " .. + "mp(build=%.2f compute=%.2f) | send(bin=%.2f dispatch=%.2f sent=%s flow=%s skipped=%d)%s", + nNodes, nEdges, totalMs, + perfStats.dropMs, perfStats.refreshMs, perfStats.mstMs, + perfStats.mstRebuilds, perfStats.mstIncrements, + perfStats.composeMs, perfStats.diffMs, + perfStats.mpBuildMs, perfStats.mpComputeMs, + perfStats.binMs, perfStats.dispatchMs, + tostring(sentThisTick), tostring(cableFlowMode), + perfStats.skipped or 0, + rebuildLine)) + -- Reset per-tick stats so the next tick starts clean. + perfStats.binMs, perfStats.dispatchMs = 0, 0 + perfStats.mpBuildMs, perfStats.mpComputeMs = 0, 0 + perfStats.worstRebuildMs, perfStats.worstRebuildN = 0, 0 + perfStats.worstRebuildHashMs = 0 + perfStats.worstRebuildNeighMs = 0 + perfStats.worstRebuildPrimMs = 0 + end + end +end + +local DETAIL_KEYS = { off = DETAIL_OFF, noflow = DETAIL_NOFLOW, full = DETAIL_FULL } +local DETAIL_NAMES = { [DETAIL_OFF] = "off", [DETAIL_NOFLOW] = "noflow", [DETAIL_FULL] = "full" } + +-- Single point that mutates the visualisation state. Sets cableEnabled + +-- cableFlowMode atomically so the FS uniform and the topology-loop gating +-- stay consistent. Persists to Spring config and forces one immediate send +-- so the new state shows up without waiting for the next tick. +local function SetDetailLevel(level) + if level == cableDetail then return end + cableDetail = level + cableEnabled = level ~= DETAIL_OFF + cableFlowMode = level == DETAIL_FULL + Spring.SetConfigInt("OverdriveCableDetail", level) + if level == DETAIL_OFF then + ClearAll() + else + -- Reset stability snapshots so the next SendAll definitely fires + -- (toggling between noflow ↔ full needs to push the new flow values + -- to the renderer; the FS uniform also needs the new enableFlow). + lastSentFlow = {} + lastSentEff = {} + ticksSinceSend = FORCE_SEND_TICKS -- force-send next tick + topologyDirty = true + SendAll() + topologyDirty = false + end +end + +-- /cabletree detail off|noflow|full — set detail level (the menu widget +-- drives this; can also be typed) +-- /cabletree ghosts on|off — toggle per-segment ghost tracking +-- /cabletree ghosts dump — print coverage bits for a slot +-- /cabletree ghosts dumpkey — print coverage bits for an edgeKey +-- /cabletree perf — toggle per-cycle timing log +-- /cabletree status — print current state +local function ToUint(v) + local n = math.floor((v or 0) + 0.5) + if n < 0 then n = n + 4294967296 end + return n +end + +local function FormatCoverage(slot) + if not coverageSSBO or not slot or slot < 0 then return "—" end + -- LuaVBOImpl::Download maps the buffer with GL_MAP_READ_BIT, which by + -- spec does NOT flush incoherent shader writes. Without an explicit + -- glMemoryBarrier, GS atomicOr writes are invisible to the readback. + -- Recoil exposes gl.MemoryBarrier (LuaOpenGL.cpp:2945); barrier flags + -- in LuaConstGL.cpp. + if gl.MemoryBarrier and GL.SHADER_STORAGE_BARRIER_BIT then + gl.MemoryBarrier(GL.SHADER_STORAGE_BARRIER_BIT + GL.BUFFER_UPDATE_BARRIER_BIT) + end + -- Detach the indexed binding before MapBuffer (mirrors printf pattern). + coverageSSBO:UnbindBufferRange(6) + local data = coverageSSBO:Download(0, slot, 1, true) or {} + local x, y, z, w = ToUint(data[1]), ToUint(data[2]), ToUint(data[3]), ToUint(data[4]) + -- Convert .x to 24-bit binary string aligned with MAX_SEGMENTS. + local bits = {} + for i = 23, 0, -1 do + bits[#bits + 1] = (math.floor(x / (2 ^ i)) % 2 == 1) and "1" or "0" + end + return string.format("x=0x%06x y=0x%x z=0x%x w=0x%x bits=%s", + x, y, z, w, table.concat(bits)) +end + +local function CableTreeCmd(cmd, line, words, playerID) + local arg = (words and words[1]) or "" + if arg == "detail" then + local key = (words and words[2]) or "" + local lvl = DETAIL_KEYS[key] + if lvl then + SetDetailLevel(lvl) + Spring.Echo("[CableTree] detail=" .. DETAIL_NAMES[cableDetail]) + else + Spring.Echo("[CableTree] usage: /cabletree detail off|noflow|full") + end + elseif arg == "ghosts" then + local sub = (words and words[2]) or "" + if sub == "on" or sub == "off" then + cableGhosts = (sub == "on") + Spring.SetConfigInt("OverdriveCableGhosts", cableGhosts and 1 or 0) + -- Toggling OFF: drop every snapshotted ghost edge and free its slot + -- so the next time it toggles on the user starts from a clean slate. + -- Otherwise the ghostEdges table would persist and the next ON would + -- revive every dead enemy edge from this match. + if not cableGhosts then + for k in pairs(ghostEdges) do + ghostEdges[k] = nil + FreeSlot(k) + end + ghostNeedsRebuild = true + end + Spring.Echo("[CableTree] ghosts " .. (cableGhosts and "ON" or "OFF")) + elseif sub == "dump" then + local slot = tonumber(words and words[3] or "") + if slot then + Spring.Echo(string.format("[CableTree] coverage[%d] = %s", + slot, FormatCoverage(slot))) + else + Spring.Echo("[CableTree] usage: /cabletree ghosts dump ") + end + elseif sub == "dumpkey" then + local key = words and words[3] or "" + local slot = slotByKey[key] + if slot then + Spring.Echo(string.format("[CableTree] coverage[%s slot=%d] = %s", + key, slot, FormatCoverage(slot))) + else + Spring.Echo("[CableTree] no slot for edgeKey '" .. key .. "'") + end + else + local nSlots = 0 + for _ in pairs(slotByKey) do nSlots = nSlots + 1 end + Spring.Echo(string.format( + "[CableTree] ghosts %s; %d slots in use, nextSlot=%d, free=%d", + cableGhosts and "ON" or "OFF", + nSlots, nextSlot, #freeSlots)) + Spring.Echo("[CableTree] usage: /cabletree ghosts on|off | dump | dumpkey ") + end + elseif arg == "perf" then + cablePerf = not cablePerf + Spring.Echo("[CableTree] perf logging " .. (cablePerf and "ON" or "OFF")) + elseif arg == "status" then + local nEdges = 0 + for _ in pairs(edges) do nEdges = nEdges + 1 end + local nSlots = 0 + for _ in pairs(slotByKey) do nSlots = nSlots + 1 end + Spring.Echo(string.format( + "[CableTree] detail=%s perf=%s ghosts=%s edges=%d slots=%d", + DETAIL_NAMES[cableDetail], tostring(cablePerf), + tostring(cableGhosts), nEdges, nSlots)) + else + Spring.Echo("[CableTree] usage: /cabletree detail off|noflow|full | ghosts | perf | status") + end + return true +end + +------------------------------------------------------------------------------------- +-- Unit lifecycle: only track node positions +------------------------------------------------------------------------------------- + +function gadget:UnitCreated(unitID, unitDefID, unitTeam) + if not pylonDefs[unitDefID] then return end + local allyTeamID = spGetUnitAllyTeam(unitID) + local x, _, z = spGetUnitPosition(unitID) + nodes[allyTeamID][unitID] = { + x = x, z = z, + range = pylonDefs[unitDefID], + unitDefID = unitDefID, + } + allyOfUnit[unitID] = allyTeamID + nodeDefByUID[unitID] = unitDefID + if consumerByDef[unitDefID] then + consumerNodeIndex[unitID] = unitDefID + end + -- Add to global spatial hash + compute neighbours (bidirectional, written + -- into both this pylon's and each candidate's pylonNeighbours table). + PylonAddSpatial(allyTeamID, unitID, x, z) + PylonBuildNeighbours(allyTeamID, unitID) +end + +function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) + -- Don't remove from nodes/lastGridNum here. + -- SyncWithGrid will detect the dead unit via spValidUnitID, + -- mark the affected grid as changed, and clean up. +end + +function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) + if not pylonDefs[unitDefID] then return end + local _, _, _, _, _, newAlly = Spring.GetTeamInfo(newTeam, false) + local _, _, _, _, _, oldAlly = Spring.GetTeamInfo(oldTeam, false) + if not newAlly or not oldAlly then return end + if newAlly ~= oldAlly then + -- Old ally's grid loses this specific pylon: queue an incremental + -- remove so SyncWithGrid patches the MST without rebuilding it. + MarkGridRemove(oldAlly, lastGridNum[unitID], unitID) + local oldNode = nodes[oldAlly] and nodes[oldAlly][unitID] + if oldNode then + PylonRemoveSpatial(oldAlly, unitID, oldNode.x, oldNode.z) + end + PylonClearNeighbours(unitID) + if nodes[oldAlly] then nodes[oldAlly][unitID] = nil end + lastGridNum[unitID] = nil + allyOfUnit[unitID] = nil + nodeDefByUID[unitID] = nil + if nodes[newAlly] then + local x, _, z = spGetUnitPosition(unitID) + nodes[newAlly][unitID] = { + x = x, z = z, + range = pylonDefs[unitDefID], + unitDefID = unitDefID, + } + allyOfUnit[unitID] = newAlly + nodeDefByUID[unitID] = unitDefID + if consumerByDef[unitDefID] then + consumerNodeIndex[unitID] = unitDefID + end + PylonAddSpatial(newAlly, unitID, x, z) + PylonBuildNeighbours(newAlly, unitID) + else + consumerNodeIndex[unitID] = nil + lastConsumerDcur[unitID] = nil + end + end +end + +-- Topology setup: registers chat command, scans pre-existing pylons (for +-- luarules-reload paths). Called from gadget:Initialize below — the rendering +-- half's Initialize is the one entry point now that there is no synced tier. +local function InitTopology() + gadgetHandler:AddChatAction("cabletree", CableTreeCmd) + -- First pass: register every existing pylon in nodes + spatial hash. + -- Second pass: build neighbour lists (so each pylon can see the others + -- that were also added in pass one). + local seenUnits = {} + for _, unitID in ipairs(Spring.GetAllUnits()) do + local unitDefID = spGetUnitDefID(unitID) + if unitDefID and pylonDefs[unitDefID] then + local allyTeamID = spGetUnitAllyTeam(unitID) + local x, _, z = spGetUnitPosition(unitID) + nodes[allyTeamID][unitID] = { + x = x, z = z, + range = pylonDefs[unitDefID], + unitDefID = unitDefID, + } + allyOfUnit[unitID] = allyTeamID + nodeDefByUID[unitID] = unitDefID + if consumerByDef[unitDefID] then + consumerNodeIndex[unitID] = unitDefID + end + PylonAddSpatial(allyTeamID, unitID, x, z) + seenUnits[#seenUnits + 1] = { unitID, allyTeamID } + end + end + for i = 1, #seenUnits do + PylonBuildNeighbours(seenUnits[i][2], seenUnits[i][1]) + end +end + +------------------------------------------------------------------------------------- +-- Rendering side: shader-based cable drawing via DrawWorldPreUnit. Cables are +-- drawn as quad strips projected onto ground height; fragment shader provides +-- procedural organic texture + LOS-gated animation. +------------------------------------------------------------------------------------- + +local spGetMyAllyTeamID = Spring.GetMyAllyTeamID +local spGetSpectatingState = Spring.GetSpectatingState +local spGetGroundHeight = Spring.GetGroundHeight + +local floor = math.floor +local sqrt = math.sqrt +local max = math.max +local min = math.min +local abs = math.abs +local PI = math.pi +local cos = math.cos +local sin = math.sin +local atan2 = math.atan2 + +local MAP_WIDTH = Game.mapSizeX +local MAP_HEIGHT = Game.mapSizeZ + +local luaShaderDir = "LuaUI/Widgets/Include/" +local LuaShader = VFS.Include(luaShaderDir .. "LuaShader.lua") +VFS.Include(luaShaderDir .. "instancevbotable.lua") + +------------------------------------------------------------------------------------- +-- Config +------------------------------------------------------------------------------------- + +local MIN_TRUNK_WIDTH = 3 +local MAX_TRUNK_WIDTH = 12 +-- One singu's output (energysingu.energyMake = 225) saturates the cable to +-- max thickness. Below that, thickness scales linearly with capacity. +local MAX_CAPACITY_REF = 225 + +local SEG_LENGTH = 10 -- shorter = smoother curves +-- Noise amplitude is in absolute elmos (not a fraction of cable width). Tying +-- it to width made thick trunks visibly more wobbly than thin twigs, which is +-- the opposite of the intended look (a thick trunk should read as "stable"). +local NOISE_AMP_ABS = 1.0 +local BRANCH_CHANCE = 0.25 +local BRANCH_LEN_MIN = 15 +local BRANCH_LEN_MAX = 50 +local BRANCH_ANGLE_MIN = 0.4 +local BRANCH_ANGLE_MAX = 1.1 +local BRANCH_WIDTH = 0.5 + +local MERGE_ANGLE = 0.8 +local STEM_FRACTION = 0.35 + +-- Visual grow/wither animation rates (elmos/sec); fragment shader trims geometry. +local GROWTH_RATE = 250 +local WITHER_RATE = 400 +local GAME_SPEED = Game.gameSpeed or 30 + +-- Bubble speed mapping — must mirror the formula in the fragment shader. We +-- integrate phase = ∫ speed(t) dt CPU-side per edge, so speed changes don't +-- jump bubbles across the cable; the shader just extrapolates from the last +-- anchor with the current speed. +-- +-- Cable-thickness/capacity is treated as orthogonal identity (it's the cable's +-- "how big a pipe" reading, NOT a flow signal). Flow itself is encoded by +-- speed + density only. Each scales as sqrt(flow / FLOW_REF) and they grow +-- together, so the product (= perceived flow ≈ density × speed) is linear in +-- flow. One unified "more lively" gestalt instead of three integrated dials. +local BUBBLE_MAX_SPEED = 110 +local BUBBLE_FLOW_REF = 50.0 -- flow at which n=1 (reference speed/density) +local BUBBLE_TRUNK_W_MIN = 3.0 -- mirror of GLSL MIN_TRUNK_WIDTH +local BUBBLE_TRUNK_W_MAX = 12.0 -- mirror of GLSL MAX_TRUNK_WIDTH +local BUBBLE_CAP_REF = MAX_CAPACITY_REF + +local function widthOfCapacity(cap) + local t = (cap or 0) / BUBBLE_CAP_REF + if t < 0 then t = 0 elseif t > 1 then t = 1 end + return BUBBLE_TRUNK_W_MIN + t * (BUBBLE_TRUNK_W_MAX - BUBBLE_TRUNK_W_MIN) +end + +-- Slight negative bias for thicker cables: divide flow by (width/minWidth). +-- A max-thickness cable (4× minWidth) sees its flow signal scaled to 1/4 +-- before the sqrt, yielding ~0.5× visual liveliness vs a thin cable at the +-- same actual flow. Conveys "this thick cable is wide so the same flow looks +-- relatively calmer through it" without the heavier 2.5-power weighting we +-- tried before. +local function flowToSpeed(flow, capacity) + if not flow or flow <= 0 then return 0 end + local widthVal = widthOfCapacity(capacity) + local thicknessRatio = widthVal / BUBBLE_TRUNK_W_MIN + local effFlow = flow / thicknessRatio + return BUBBLE_MAX_SPEED * math.sqrt(effFlow / BUBBLE_FLOW_REF) +end + +------------------------------------------------------------------------------------- +-- State +------------------------------------------------------------------------------------- + +-- edgesByAllyTeam[ally][edgeKey] = { px, pz, cx, cz, capacity, appearFrame, witherFrame } +local edgesByAllyTeam = {} +local renderEdges = {} +local renderEdgesByKey = {} -- flat lookup: edgeKey -> renderEdge entry +local needsRebuild = false + +-- Orphaned enemy edges that the local viewer has seen at least one segment of +-- in LOS. The synced gadget broadcasts every ally team's grid to all clients, +-- but a player shouldn't *learn* that an enemy edge died until they re-scout +-- the area. We snapshot the geometry the moment synced removes the edge and +-- render it via a separate VBO using the same shader; the FS gates it by the +-- per-segment coverage bits the live pass set. +-- +-- ghostEdges[edgeKey] = { px, pz, cx, cz, capacity, slot, key } +-- The slot reference keeps the SSBO entry alive (we don't FreeSlot until the +-- ghost itself retires after a re-scout-clear pass). +-- (ghostEdges and ghostNeedsRebuild are forward-declared near the top of +-- the file so CableTreeCmd can reach them.) +local ghostVAO +local ghostVBO -- reused across RebuildGhostVBO calls; capacity grows as needed +local ghostVBOCapacity = 0 -- elements the current ghostVBO was last Defined for +local numGhostVerts = 0 + +-- Rolling cleanup cursor: each tick we scan a small slice of ghostEdges +-- (3-point IsPosInLos) instead of scanning all of them every 30 frames. +-- Keeps per-frame cost flat and predictable regardless of ghost count. +local ghostCleanupCursor = nil -- next key to start at; nil = restart from head +local GHOST_CLEANUP_PER_FRAME = 32 + +-- Geometry cache. Topology-stable rebuilds reuse `allPaths` (the noisy paths, +-- twigs and cluster stems). Per-call, we walk the prov objects (one per +-- emitNoisyPath invocation) and refresh just the dynamic fields (flow, eff, +-- bubblePhase, appearFrame, witherFrame) from the current renderEdges. +-- Vert emission then re-reads from prov. +-- +-- Invalidated by: new edge in OnCableTreeFull, edge marked withering, +-- withering edge dropped in GameFrame, cluster sign-flip during refresh. +local geomCache = { + valid = false, + allPaths = nil, -- [{ points, widths, capacity, isBranch, prov }] + provs = nil, -- [provObj] (distinct, one per emitNoisyPath call) +} + +local cableShader -- forward shader for cable rendering +local cableVAO -- live cable geometry +local numCableVerts = 0 +-- (drawPerf collapsed into cablePerf at the top of the file; flowMode +-- collapsed into cableFlowMode. Both names live in the topology block above.) +-- Game-second timestamp captured the moment the current VBO's bubblePhase +-- snapshots were taken. The shader extrapolates each cable's phase forward +-- from this anchor using `phase = bakedPhase + flowToSpeed(flow) * (gameTime +-- - bakeTime)`, which means flow changes update the rate of advance without +-- ever teleporting the bubbles. +local bubbleBakeTime = 0 + +------------------------------------------------------------------------------------- +-- Deterministic noise +------------------------------------------------------------------------------------- + +local function Hash(x, z, seed) + local h = sin(x * 12.9898 + z * 78.233 + (seed or 0) * 43.17) * 43758.5453 + return (h - floor(h)) * 2 - 1 +end + +local function HashUnit(x, z, seed) + return (Hash(x, z, seed) + 1) * 0.5 +end + +local function GetTrunkWidth(capacity) + local t = min(1, capacity / MAX_CAPACITY_REF) + return MIN_TRUNK_WIDTH + t * (MAX_TRUNK_WIDTH - MIN_TRUNK_WIDTH) +end + +local function NoisyPath(x1, z1, x2, z2, amplitude, seed) + local dx = x2 - x1 + local dz = z2 - z1 + local len = sqrt(dx * dx + dz * dz) + if len < 2 then + return { {x = x1, z = z1}, {x = x2, z = z2} } + end + local steps = max(2, floor(len / SEG_LENGTH)) + local nx = -dz / len + local nz = dx / len + local points = {} + -- Cap effective amplitude by a fraction of the segment length: very short + -- cables shouldn't get the same wiggle as long ones. + local effAmp = amplitude + if len < 80 then effAmp = amplitude * (len / 80) end + for i = 0, steps do + local t = i / steps + local px = x1 + t * dx + local pz = z1 + t * dz + local noiseScale = 1 + if t < 0.1 then noiseScale = t / 0.1 + elseif t > 0.9 then noiseScale = (1 - t) / 0.1 end + local n = Hash(px * 0.1, pz * 0.1, seed) * effAmp * noiseScale + points[#points + 1] = { x = px + nx * n, z = pz + nz * n } + end + return points +end + +------------------------------------------------------------------------------------- +-- Organic tree router (same logic, outputs vertex data for VBO) +------------------------------------------------------------------------------------- + +local function normalizeAngle(a) + while a > PI do a = a - PI * 2 end + while a < -PI do a = a + PI * 2 end + return a +end + +-- Per-edge VBO build: emit two vertices per cable (the two endpoints), each +-- carrying the same per-edge payload. The geometry shader expands each line +-- into the noisy wiggly ribbon. CPU work shrinks from "build full triangle +-- soup" (lots of NoisyPath / clustering / twig generation) to "iterate edges". +-- Cluster stems and twigs are deliberately gone in this first GS pass — to be +-- reintroduced as either CPU-emitted phantom edges (stems) or GS-side branches +-- (twigs) once the basic pipeline is verified. +local function GenerateOrganicTree() + local n = #renderEdges + if n == 0 then return {}, 0 end + + local verts = {} + local k = 0 + for i = 1, n do + local e = renderEdges[i] + local cap = max(1, e.capacity or 1) + local appearTime = (e.appearFrame or 0) / GAME_SPEED + local witherTime = e.witherFrame and (e.witherFrame / GAME_SPEED) or 0 + local eff = e.eff or 0 + local flow = e.flow or 0 + local phase = e.bubblePhase or 0 + local isOwn = e.isOwnAlly and 1 or 0 + -- Coverage SSBO slot index, or -1 to disable bit updates / lookups + -- on the GS side. Stored as float here for VBO layout simplicity; + -- VS casts to int for the GS to consume. Negative values fit fine + -- through the float<-int round-trip for any reasonable slot count. + local slot = e.slot or -1 + + -- Vertex 0: parent end (10 floats: pos2 + data3 + grid4 + slot) + verts[k+1] = e.px; verts[k+2] = e.pz + verts[k+3] = cap; verts[k+4] = appearTime; verts[k+5] = witherTime + verts[k+6] = eff; verts[k+7] = flow; verts[k+8] = phase; verts[k+9] = isOwn + verts[k+10] = slot + -- Vertex 1: child end (same per-edge payload) + verts[k+11] = e.cx; verts[k+12] = e.cz + verts[k+13] = cap; verts[k+14] = appearTime; verts[k+15] = witherTime + verts[k+16] = eff; verts[k+17] = flow; verts[k+18] = phase; verts[k+19] = isOwn + verts[k+20] = slot + k = k + 20 + end + return verts, n * 2 +end + +-- Build verts for the ghost VBO from `ghostEdges`. Same per-vertex layout as +-- the live pass (10 floats), so the same VS/GS/FS chain handles both. The FS +-- distinguishes ghosts via gridData.w = -1.0 (sentinel; live edges send 0/1). +local function GenerateGhostTree() + local verts = {} + local k = 0 + local count = 0 + for _, e in pairs(ghostEdges) do + local cap = max(1, e.capacity or 1) + local slot = e.slot or -1 + -- Ghost edges have no temporal animation: appear=0, wither=0, no flow, + -- no bubble phase. gridData.w = -1.0 tells the FS "always ghost". + local apT, wiT = 0.0, 0.0 + local eff, flow = 0.0, 0.0 + local phase = 0.0 + local ghostFlag = -1.0 + + verts[k+1] = e.px; verts[k+2] = e.pz + verts[k+3] = cap; verts[k+4] = apT; verts[k+5] = wiT + verts[k+6] = eff; verts[k+7] = flow; verts[k+8] = phase; verts[k+9] = ghostFlag + verts[k+10] = slot + verts[k+11] = e.cx; verts[k+12] = e.cz + verts[k+13] = cap; verts[k+14] = apT; verts[k+15] = wiT + verts[k+16] = eff; verts[k+17] = flow; verts[k+18] = phase; verts[k+19] = ghostFlag + verts[k+20] = slot + k = k + 20 + count = count + 1 + end + return verts, count * 2 +end + +local function RebuildGhostVBO() + ghostNeedsRebuild = false + local verts, vertCount = GenerateGhostTree() + numGhostVerts = vertCount + if vertCount == 0 or not ghostVBO then return end + -- VBO is pre-Defined to COVERAGE_MAX_SLOTS*2 verts in gadget:Initialize + -- (Spring's Define is immutable so we can't grow it later). Just stream + -- the current vert payload in. + ghostVBO:Upload(verts) +end + +-- Old generic angle clustering — kept commented as a reference for when we +-- reintroduce CPU-side stem merging (cluster decomposition is a graph +-- operation that doesn't fit cleanly in a geometry shader). + +------------------------------------------------------------------------------------- +-- Forward cable rendering via DrawWorldPreUnit. +-- Vertex shader resamples heightmap each frame so cables follow terraform. +-- Fragment shader does its own diffuse+specular lighting on a synthesized +-- cylinder normal, plus traveling energy pulses gated by LOS ($info). +------------------------------------------------------------------------------------- + +-- Cable shader sources live in dedicated .glsl files alongside the gadget +-- (LuaRules/Gadgets/Shaders/) so they get proper editor syntax highlighting +-- and the gadget itself stays focused on Lua state. The placeholder +-- '//__ENGINEUNIFORMBUFFERDEFS__' inside the GS/FS files is substituted at +-- shader-compile time in gadget:Initialize below. +local SHADER_DIR = 'LuaRules/Gadgets/Shaders/' +local cableVSSrc = VFS.LoadFile(SHADER_DIR .. 'gfx_overdrive_cables.vert.glsl') +local cableGSSrc = VFS.LoadFile(SHADER_DIR .. 'gfx_overdrive_cables.geom.glsl') +local cableFSSrc = VFS.LoadFile(SHADER_DIR .. 'gfx_overdrive_cables.frag.glsl') + +------------------------------------------------------------------------------------- +-- Receive data from synced +------------------------------------------------------------------------------------- + +-- Whether the local viewer should treat `allyTeamID`'s cables as "own" +-- (always visible, optionally ghosted out of LOS) vs "enemy" (only visible +-- inside actual LOS). Specs and full-view see everything as own. +local function isOwnAlly(allyTeamID) + local spec, fullview = spGetSpectatingState() + if (spec or fullview) then return true end + return allyTeamID == spGetMyAllyTeamID() +end + +local function RebuildRenderEdges() + renderEdges = {} + renderEdgesByKey = {} + for _, edges in pairs(edgesByAllyTeam) do + for k, e in pairs(edges) do + e.key = k + renderEdges[#renderEdges + 1] = e + renderEdgesByKey[k] = e + end + end +end + +-- In-place diff of the incoming Full snapshot against existing state: +-- survivors keep their appearFrame (no animation restart), missing edges +-- get marked withering, new edges get appearFrame = current frame. +-- Bound to the forward-declared local at the top of the file so the +-- topology side can call it directly. +function OnCableTreeFull(data) + if not data then return end + local ally = data.allyTeamID + -- Always accept; the FS gates enemy fragments by LOS so unscouted enemy + -- cables are invisible without dropping their data here. + local ownAlly = isOwnAlly(ally) + + local tStart = cablePerf and Spring.GetTimer() or nil + local frame = Spring.GetGameFrame() + local existing = edgesByAllyTeam[ally] or {} + + -- Local viewer's allyTeam — used to decide whether the player can see + -- ANY part of an enemy edge (start, midpoint, or end). Drives: + -- - whether wither/grow animations play (witnessed events). + -- - whether visible properties (capacity → ribbon width, position) + -- refresh from synced. Per "see any segment → infer whole cable + -- reflects current state": as soon as one endpoint or the midpoint + -- is in LOS we update everything; otherwise the cable holds its + -- last-seen values so unobserved builds can't leak thickness/flow + -- changes through the fog rendering. + -- 3 short-circuited IsPosInLos calls per edge per OnCableTreeFull tick; + -- bounded by sync rate (~1 Hz) and engine-side these calls are cheap. + local myAlly = spGetMyAllyTeamID() + local function anyInLOS(px, pz, cx, cz) + if ownAlly then return true end + if Spring.IsPosInLos(px, 0, pz, myAlly) then return true end + local mx, mz = (px + cx) * 0.5, (pz + cz) * 0.5 + if Spring.IsPosInLos(mx, 0, mz, myAlly) then return true end + return Spring.IsPosInLos(cx, 0, cz, myAlly) + end + + -- Build a fast lookup of incoming keys. + local incoming = {} + for i = 1, data.edgeCount do + incoming[data.keys[i]] = i + end + + -- Refresh cached own/enemy flag for every edge of this ally before any + -- branching below uses it. Without this, a /team or spec view switch + -- leaves stale e.isOwnAlly = true on ex-own edges; the disappear loop + -- would then take the own-ally branch and play a wither animation in + -- fog instead of snapshotting the dead edge to a ghost. The GameFrame + -- wither-snapshot path also reads e.isOwnAlly, so the refresh has to + -- land on every edge, not just survivors of this tick. + for _, e in pairs(existing) do + e.isOwnAlly = ownAlly + end + + -- Edges that synced no longer reports. + -- Own-ally → start withering animation (player knows their grid lost a + -- pylon, the visual reflects that). + -- Enemy → snapshot into ghostEdges and remove from live immediately. The + -- player doesn't know the cable died, so it should keep rendering as a + -- ghost at the last-seen segments until they re-scout. Wither animation + -- is skipped because that would leak the death event. + -- When cableGhosts is OFF, enemy edges that disappear are simply removed + -- (no ghost snapshot, no rendering cost) — same behaviour as before the + -- ghost feature existed. + for k, e in pairs(existing) do + if not incoming[k] and not e.witherFrame then + if not ownAlly then + -- If the player can see the midpoint right now, play the + -- wither animation in-place; the snapshot to ghost happens + -- later in GameFrame when wither completes (see WITHER_HOLD + -- handler). Out of LOS, snapshot immediately and silently. + if anyInLOS(e.px, e.pz, e.cx, e.cz) then + e.witherFrame = frame + else + if cableGhosts and e.slot and e.slot >= 0 then + ghostEdges[k] = { + px = e.px, pz = e.pz, cx = e.cx, cz = e.cz, + capacity = e.capacity or 0, + slot = e.slot, + key = k, + } + ghostNeedsRebuild = true + end + existing[k] = nil + end + else + e.witherFrame = frame + end + geomCache.valid = false -- topology change → full geometry rebuild + end + end + + -- If a fresh edge resurrects an old ghost (enemy rebuilt the same pylon + -- pair, or MST rerouted back through it), drop the ghost — live takes + -- over. PRESERVE the SSBO bits so the player keeps seeing the same + -- segments as memory; mark the resurrection so the fresh-edge branch + -- below skips the growth animation (otherwise the seen segments would + -- play a "ghost regrow" cropping from u=0 — visually wrong because the + -- player has already seen those segments). + local resurrectedKeys + for k in pairs(incoming) do + if ghostEdges[k] then + ghostEdges[k] = nil + ghostNeedsRebuild = true + resurrectedKeys = resurrectedKeys or {} + resurrectedKeys[k] = true + end + end + + -- Add new, refresh capacity / flow / efficiency on survivors. + -- For each surviving edge, we integrate its bubble phase up to NOW with + -- the *old* speed before swapping in the new one. That way, when flow + -- (and hence speed) changes, the bubble position remains continuous — + -- it just starts evolving at a different rate from this moment on. + local nowSec = Spring.GetGameSeconds() + for k, i in pairs(incoming) do + local e = existing[k] + local newFlow = data.flows and data.flows[i] or 0 + -- Withering edge that's resurrected by the new snapshot: cancel + -- the wither, treat as a survivor (no growth restart). Otherwise + -- the cable would finish withering and disappear despite synced + -- saying it's back. + if e and e.witherFrame then + e.witherFrame = nil + end + if e and not e.witherFrame then + -- Catch the phase up to `nowSec` using whatever speed the cable + -- was running at since its last anchor. + local oldSpeed = e.bubbleSpeed or 0 + local oldAnchor = e.bubbleAnchorTime or nowSec + e.bubblePhase = (e.bubblePhase or 0) + oldSpeed * (nowSec - oldAnchor) + e.bubbleAnchorTime = nowSec + e.bubbleSpeed = flowToSpeed(newFlow, data.caps[i]) + + -- Visible properties (capacity drives ribbon width, position drives + -- the chord) only refresh when the player can see the edge. For + -- enemy edges in fog we keep the last-seen values so a build that + -- changes an unobserved cable's thickness doesn't suddenly update + -- its ghost render. Bubble phase / flow / eff are also only + -- meaningful in LOS (they animate the live render), so refreshing + -- them in fog is harmless but we keep the gate uniform. + local visible = ownAlly or anyInLOS(data.pxs[i], data.pzs[i], data.cxs[i], data.czs[i]) + if visible then + e.capacity = data.caps[i] + e.flow = newFlow + e.eff = data.effs and data.effs[i] or 0 + e.px, e.pz = data.pxs[i], data.pzs[i] + e.cx, e.cz = data.cxs[i], data.czs[i] + end + e.isOwnAlly = ownAlly + -- Late-bind a coverage slot if missing (e.g., gadget was reloaded + -- after the SSBO was added but with pre-existing edges). + if not e.slot then e.slot = AllocSlot(k) or -1 end + else + -- Fresh edge: allocate a coverage SSBO slot. Slot is keyed by the + -- unitID-pair (k = "minUid:maxUid") so reroutes across the same + -- pylons stay in the same slot. Slot returns nil if the pool is + -- exhausted (4096 simultaneous tracked edges); we still create + -- the edge but with slot = -1 (GS treats as "don't update bits"). + local slot = AllocSlot(k) or -1 + -- Growth animation policy: + -- own ally → grow (player just built this). + -- enemy in LOS → grow (player witnessed it being built). + -- enemy in fog → no growth (player doesn't know about it; growth + -- would crop in from u=0 = misleading). + -- resurrected ghost → no growth (continuity of memory). + local af + if resurrectedKeys and resurrectedKeys[k] then + af = 0 + elseif ownAlly or anyInLOS(data.pxs[i], data.pzs[i], data.cxs[i], data.czs[i]) then + af = frame + else + af = 0 + end + existing[k] = { + px = data.pxs[i], pz = data.pzs[i], + cx = data.cxs[i], cz = data.czs[i], + capacity = data.caps[i], + flow = newFlow, + eff = data.effs and data.effs[i] or 0, + isOwnAlly = ownAlly, + appearFrame = af, + witherFrame = nil, + key = k, + slot = slot, + -- Fresh edge starts with zero phase; speed is set so the + -- shader can extrapolate forward from this anchor. + bubblePhase = 0, + bubbleAnchorTime = nowSec, + bubbleSpeed = flowToSpeed(newFlow, data.caps[i]), + } + geomCache.valid = false -- topology change → full geometry rebuild + end + end + + edgesByAllyTeam[ally] = existing + local tDiff = cablePerf and Spring.GetTimer() or nil + RebuildRenderEdges() + needsRebuild = true + + if cablePerf then + local tEnd = Spring.GetTimer() + Spring.Echo(string.format( + "[CableTree] OnCableTreeFull: diff=%.2f ms rebuildIdx=%.2f ms edges=%d", + Spring.DiffTimers(tDiff, tStart) * 1000, + Spring.DiffTimers(tEnd, tDiff) * 1000, + data.edgeCount)) + end +end + +------------------------------------------------------------------------------------- +-- VBO rebuild +------------------------------------------------------------------------------------- + +local function RebuildVBO() + local tStart = cablePerf and Spring.GetTimer() or nil + + -- Snapshot every edge's bubble phase to NOW before geometry generation, + -- and re-anchor; the shader will extrapolate from `bubbleBakeTime`. + bubbleBakeTime = Spring.GetGameSeconds() + for _, edges in pairs(edgesByAllyTeam) do + for _, e in pairs(edges) do + local oldSpeed = e.bubbleSpeed or 0 + local oldAnchor = e.bubbleAnchorTime or bubbleBakeTime + e.bubblePhase = (e.bubblePhase or 0) + oldSpeed * (bubbleBakeTime - oldAnchor) + e.bubbleAnchorTime = bubbleBakeTime + end + end + + local tGen0 = cablePerf and Spring.GetTimer() or nil + local verts, vertCount = GenerateOrganicTree() + if vertCount == 0 then + numCableVerts = 0 + needsRebuild = false + return + end + + cableVAO = nil + local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) + if not vbo then return end + -- Per-vertex layout (10 floats): vertPos(2) + vertData(3) + vertGrid(4) + -- + vertSlot(1). Two vertices per cable form one GL_LINES primitive; the + -- geometry shader expands each line into a wiggly ribbon at draw time. + vbo:Define(vertCount, { + { id = 0, name = "vertPos", size = 2 }, + { id = 1, name = "vertData", size = 3 }, -- (capacity, appearTime, witherTime) + { id = 2, name = "vertGrid", size = 4 }, -- (efficiency, flow E/s, bubble phase elmos, isOwnAlly) + { id = 3, name = "vertSlot", size = 1 }, -- coverage SSBO slot, or -1 + }) + local tUp0 = cablePerf and Spring.GetTimer() or nil + vbo:Upload(verts) + cableVAO = gl.GetVAO() + if cableVAO then cableVAO:AttachVertexBuffer(vbo) end + numCableVerts = vertCount + needsRebuild = false + + if cablePerf then + local tEnd = Spring.GetTimer() + Spring.Echo(string.format( + "[CableTree] draw rebuild: phase=%.2f ms build=%.2f ms upload=%.2f ms verts=%d edges=%d", + Spring.DiffTimers(tGen0, tStart) * 1000, + Spring.DiffTimers(tUp0, tGen0) * 1000, + Spring.DiffTimers(tEnd, tUp0) * 1000, + vertCount, vertCount / 2)) + end +end + +------------------------------------------------------------------------------------- +-- Drawing via DrawWorldPreUnit (forward, opaque) +------------------------------------------------------------------------------------- + +-- Conservative cap on how long a withering edge stays in geometry; the +-- fragment shader has already discarded its pixels long before this. +-- Worst case path length ~2000 elmos / 400 elmos/sec = 5s; pad to be safe. +local WITHER_HOLD_FRAMES = 8 * GAME_SPEED + +function gadget:GameFrame(n) + -- 1) Topology refresh (was previously a synced gadget:GameFrame). Runs + -- on the SYNC_PERIOD cadence, may invoke OnCableTreeFull (sets + -- needsRebuild) and update edgesByAllyTeam. + RunSyncTick(n) + + -- 2) Drop fully-withered edges so geometry doesn't grow unboundedly. + -- Enemy edges that withered in LOS get snapshotted to ghostEdges here + -- (deferred from OnCableTreeFull so the player sees the full wither + -- animation first). After the snapshot, the cable seamlessly continues + -- to render via the ghost VBO from previously-seen segments. + local dropped = false + for ally, edges in pairs(edgesByAllyTeam) do + for k, e in pairs(edges) do + if e.witherFrame and (n - e.witherFrame) >= WITHER_HOLD_FRAMES then + if not e.isOwnAlly and cableGhosts and e.slot and e.slot >= 0 then + ghostEdges[k] = { + px = e.px, pz = e.pz, cx = e.cx, cz = e.cz, + capacity = e.capacity or 0, + slot = e.slot, + key = k, + } + ghostNeedsRebuild = true + end + edges[k] = nil + dropped = true + end + end + end + if dropped then + RebuildRenderEdges() + needsRebuild = true + geomCache.valid = false + end + + -- 3) Rebuild immediately when dirty. Throttling caused visible phase + -- jumps: between OnCableTreeFull (which mutates per-edge bubbleSpeed) + -- and the rebake, the shader still extrapolates with the OLD speed, + -- then snaps to the new baked state. The jump magnitude is + -- Δspeed × (bakeTime - nowSec) so any latency here directly produces + -- a visible discontinuity. + if needsRebuild then + RebuildVBO() + end + + -- 4) Ghost VBO follows the orphaned-enemy table. Rebuilds are rare — + -- only when an enemy edge dies (snapshot in) or resurrects (drop). + if ghostNeedsRebuild then + RebuildGhostVBO() + end + + -- 5) Ghost cleanup — rolling, amortised. Every frame we scan up to + -- GHOST_CLEANUP_PER_FRAME entries via a cursor that advances through + -- the table, wrapping back to the start when it falls off the end. + -- Net: every ghost gets checked roughly every (count/perFrame) frames + -- regardless of total count, with bounded per-frame engine-call cost + -- (3 × IsPosInLos per scanned entry). Replaces the prior + -- "iterate everything every 30 frames" stutter. + if cableGhosts and next(ghostEdges) then + local spec, fullView = spGetSpectatingState() + if not (spec or fullView) then + local ally = spGetMyAllyTeamID() + local removed = false + local checked = 0 + local k = ghostCleanupCursor and ghostEdges[ghostCleanupCursor] and ghostCleanupCursor + while checked < GHOST_CLEANUP_PER_FRAME do + k = next(ghostEdges, k) + if k == nil then break end -- end of table; cursor wraps next tick + local e = ghostEdges[k] + local mx, mz = (e.px + e.cx) * 0.5, (e.pz + e.cz) * 0.5 + if Spring.IsPosInLos(e.px, 0, e.pz, ally) + and Spring.IsPosInLos(mx, 0, mz, ally) + and Spring.IsPosInLos(e.cx, 0, e.cz, ally) then + ghostEdges[k] = nil + FreeSlot(k) + removed = true + end + checked = checked + 1 + end + ghostCleanupCursor = k -- nil = restart; otherwise resume here + if removed then ghostNeedsRebuild = true end + end + end +end + +function gadget:DrawWorldPreUnit() + -- Honour the menu toggle immediately, even while paused (RebuildVBO + -- only fires from GameFrame, which doesn't tick during pause; without + -- this gate the cables would linger on screen until unpause). + if not cableEnabled then return end + if not cableVAO or numCableVerts == 0 or not cableShader then return end + + cableShader:Activate() + -- Smooth gameTime: GetGameSeconds() ticks at the sim rate (GAME_SPEED). + -- At higher game speeds each sim step covers more game-time, so the + -- per-frame phase delta the FS sees gets bigger and bubbles visibly jump + -- between sim ticks. Adding GetFrameTimeOffset() (the [0,1] fraction + -- through the current sim interval, used by the engine for visual interp) + -- divided by GAME_SPEED gives a continuous time that advances smoothly + -- between sim ticks on all game speeds. + local frameOff = Spring.GetFrameTimeOffset and Spring.GetFrameTimeOffset() or 0 + cableShader:SetUniform("gameTime", Spring.GetGameSeconds() + frameOff / GAME_SPEED) + cableShader:SetUniform("bakeTime", bubbleBakeTime) + cableShader:SetUniform("enableFlow", cableFlowMode and 1.0 or 0.0) + cableShader:SetUniform("ghostsEnabled", cableGhosts and 1.0 or 0.0) + + -- Bind the per-edge coverage SSBO at binding=6. Both the live and ghost + -- VBO draws use the same shader program so a single binding covers them. + if coverageSSBO then + local b = coverageSSBO:BindBufferRange(6) + if cablePerf and Spring.GetGameFrame() % 30 == 0 then + Spring.Echo("[CableTree] SSBO BindBufferRange(6) -> " .. tostring(b)) + end + end + + -- $info:los is the actual game-logic LOS texture (single-channel red), NOT + -- the user's visual LOS-overlay (which is what plain $info samples and which + -- becomes a height-map view when the overlay is toggled off — defeating any + -- LOS gating done against it). + gl.Texture(0, "$info:los") + gl.Texture(1, "$heightmap") + gl.Culling(false) + gl.DepthTest(GL.LEQUAL) + gl.DepthMask(true) + -- Live cable pass: opaque (FS writes alpha=1.0). Blending enabled + -- globally is harmless here since src=1, dst=0 → identity. + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + cableVAO:DrawArrays(GL.LINES, numCableVerts) + + -- Ghost pass: orphaned enemy edges, semi-transparent flat-gray render. + -- DepthMask off so ghosts don't occlude live cables behind them and so + -- the alpha-blend composes against terrain without writing depth that + -- would clamp later geometry. + if cableGhosts and ghostVAO and numGhostVerts > 0 then + gl.DepthMask(false) + ghostVAO:DrawArrays(GL.LINES, numGhostVerts) + gl.DepthMask(true) + end + + -- Make the GS atomicOr/atomicAnd writes visible to subsequent SSBO + -- consumers (the ghost pass on the next frame, plus any Download from + -- chat handlers). Without this barrier the writes live in the + -- shader-store cache and never become coherent with later map/read + -- operations on the same buffer. + if gl.MemoryBarrier and GL.SHADER_STORAGE_BARRIER_BIT then + gl.MemoryBarrier(GL.SHADER_STORAGE_BARRIER_BIT + GL.BUFFER_UPDATE_BARRIER_BIT) + end + + cableShader:Deactivate() + gl.Texture(0, false) + gl.Texture(1, false) + gl.DepthTest(false) + gl.DepthMask(false) + gl.Culling(GL.BACK) +end + +------------------------------------------------------------------------------------- +-- Lifecycle +------------------------------------------------------------------------------------- + +function gadget:Initialize() + if not gl.CreateShader or not gl.GetVBO or not gl.GetVAO then + gadgetHandler:RemoveGadget() + return + end + + local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() + local vsSrc = cableVSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + local gsSrc = cableGSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + local fsSrc = cableFSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + + + cableShader = LuaShader({ + vertex = vsSrc, + geometry = gsSrc, + fragment = fsSrc, + uniformInt = { + infoTex = 0, + heightmapTex = 1, + }, + uniformFloat = { + gameTime = 0, + bakeTime = 0, + enableFlow = cableFlowMode and 1.0 or 0.0, + ghostsEnabled = cableGhosts and 1.0 or 0.0, + }, + }, "Cable Forward Shader") + + if not cableShader:Initialize() then + Spring.Echo("[CableTree] Shader compile failed") + gadgetHandler:RemoveGadget() + return + end + + -- Per-edge coverage SSBO. Spring's VBO API requires vec4-aligned attribs, + -- so each slot is one 4-float row; we only use .x (the coverage uint). + -- Bit `i` of slot[s].x = "segment i of the cable in slot s has been in LOS + -- at least once". Persistent across frames; initial values zero. + -- gl.GetVBO(target, freqUpdated_bool). freqUpdated=true → GL_DYNAMIC_DRAW + -- on the underlying buffer, suitable for shader writes. Defaulting to + -- false marks it static, which on some drivers traps shader stores. + coverageSSBO = gl.GetVBO(GL.SHADER_STORAGE_BUFFER, true) + if coverageSSBO then + coverageSSBO:Define(COVERAGE_MAX_SLOTS, { + { id = 0, name = "coverageData", size = 4 }, + }) + local zeros = {} + for i = 1, 4 * COVERAGE_MAX_SLOTS do zeros[i] = 0 end + coverageSSBO:Upload(zeros) + else + Spring.Echo("[CableTree] SSBO unsupported; ghosting disabled") + cableGhosts = false + end + + -- Pre-allocate the ghost VBO at max plausible capacity (2 verts per + -- ghost edge, capped at COVERAGE_MAX_SLOTS edges). Spring's VBO Define + -- is immutable — call it once here, then RebuildGhostVBO only Uploads. + ghostVBO = gl.GetVBO(GL.ARRAY_BUFFER, true) + if ghostVBO then + ghostVBOCapacity = COVERAGE_MAX_SLOTS * 2 + ghostVBO:Define(ghostVBOCapacity, { + { id = 0, name = "vertPos", size = 2 }, + { id = 1, name = "vertData", size = 3 }, + { id = 2, name = "vertGrid", size = 4 }, + { id = 3, name = "vertSlot", size = 1 }, + }) + ghostVAO = gl.GetVAO() + if ghostVAO then ghostVAO:AttachVertexBuffer(ghostVBO) end + end + + -- Topology side: register chat command + scan existing pylons. + InitTopology() +end + +-- Local viewer changed team / spec state (e.g. /team N, going spec, joining +-- as player). Re-tag every edge's cached own/enemy flag against the new +-- viewer, drop ghosts whose ally is now own-side (you can see them live — +-- a ghost duplicate would render on top), and force a live VBO rebuild so +-- the per-vert isOwn flag flips this frame instead of waiting for the next +-- sync tick. Filtered to the local player so other players' team changes +-- don't trigger the work. +function gadget:PlayerChanged(playerID) + if playerID ~= Spring.GetLocalPlayerID() then return end + local touched = false + for ally, edges in pairs(edgesByAllyTeam) do + local ownAlly = isOwnAlly(ally) + for _, e in pairs(edges) do + if e.isOwnAlly ~= ownAlly then + e.isOwnAlly = ownAlly + touched = true + end + end + end + -- Ghosts only exist for enemy edges. If a previously-enemy ally is now + -- own-side (spec switched into them, or you joined the team), the live + -- render is authoritative; drop the stale ghost snapshots. + for k, g in pairs(ghostEdges) do + local edge = renderEdgesByKey and renderEdgesByKey[k] + if edge and edge.isOwnAlly then + ghostEdges[k] = nil + ghostNeedsRebuild = true + end + end + if touched then + geomCache.valid = false + needsRebuild = true + end +end + +function gadget:Shutdown() + if cableShader then cableShader:Finalize() end + cableVAO = nil +end + diff --git a/LuaUI/Widgets/gfx_overdrive_cables_menu.lua b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua new file mode 100644 index 0000000000..7bd441c146 --- /dev/null +++ b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua @@ -0,0 +1,105 @@ +-------------------------------------------------------------------------------- +-- Overdrive Cables — Settings menu entry +-- +-- Pure UI bridge for the unsynced gadget gfx_overdrive_cables.lua. Exposes a +-- three-state radio button under Settings/Graphics so users can pick the +-- detail level without typing chat commands. Persistence lives on the gadget +-- side (Spring.GetConfigInt("OverdriveCableDetail")) so disabling this +-- widget doesn't lose the user's choice — the gadget keeps reading its own +-- config key on reload. +-- +-- Communication: this widget never touches the gadget directly; it just +-- fires `/luarules cabletree detail ` via Spring.SendCommands. The +-- gadget's chat handler updates state and writes Spring config. +-------------------------------------------------------------------------------- + +function widget:GetInfo() + return { + name = "Overdrive Cables Settings", + desc = "Settings menu entry for the overdrive cable visualization.", + author = "Licho", + date = "2026", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = true, + handler = false, + } +end + +local DETAIL_KEY = "OverdriveCableDetail" +local GHOSTS_KEY = "OverdriveCableGhosts" +local KEY_BY_LEVEL = { [0] = 'off', [1] = 'noflow', [2] = 'full' } +local LEVEL_BY_KEY = { off = 0, noflow = 1, full = 2 } + +local function readCurrentDetailKey() + local v = Spring.GetConfigInt(DETAIL_KEY, 2) or 2 + return KEY_BY_LEVEL[v] or 'full' +end + +local function readCurrentGhosts() + return (Spring.GetConfigInt(GHOSTS_KEY, 1) or 1) ~= 0 +end + +options_path = 'Settings/Graphics/Overdrive Cables' +options_order = { 'cabletree_detail', 'cabletree_ghosts' } + +options = { + cabletree_detail = { + name = 'Overdrive cable visualization', + desc = 'Off: no cables drawn. Static: gray pipes only (cheapest). Full: animated bubbles indicating flow (default).', + type = 'radioButton', + items = { + { key = 'full', name = 'Full (animated bubbles)', desc = 'Default. Bubbles indicate flow direction and rate.' }, + { key = 'noflow', name = 'Static (no flow animation)', desc = 'Cheaper: gray pipes only, no per-tick flow reads or shader bubble pass.' }, + { key = 'off', name = 'Off (no cables)', desc = 'Hide the overdrive grid entirely.' }, + }, + value = 'full', + OnChange = function(self) + Spring.SendCommands("luarules cabletree detail " .. self.value) + end, + noHotkey = true, + }, + cabletree_ghosts = { + name = 'Show cable ghosts in fog', + desc = 'When on, segments of enemy cables you have scouted at least once stay visible as a flat ghost when they drop out of LOS, until you re-scout the area and confirm it is empty.', + type = 'bool', + value = true, + OnChange = function(self) + Spring.SendCommands("luarules cabletree ghosts " .. (self.value and "on" or "off")) + end, + noHotkey = true, + }, +} + +function widget:Initialize() + -- Sync the widget's displayed value to the gadget's persisted state. + -- The gadget loads first and reads its own Spring.SetConfigInt key, so + -- whatever it's running at right now is the authoritative value. Push + -- it back into the option so the menu reflects truth. + options.cabletree_detail.value = readCurrentDetailKey() + options.cabletree_ghosts.value = readCurrentGhosts() + -- And ensure the gadget agrees with whatever was saved (idempotent — + -- the gadget's setters return early if state is unchanged). + Spring.SendCommands("luarules cabletree detail " .. options.cabletree_detail.value) + Spring.SendCommands("luarules cabletree ghosts " .. (options.cabletree_ghosts.value and "on" or "off")) +end + +-- Persistence: the gadget owns the truth via Spring.GetConfigInt. We let the +-- widget framework's per-widget config (ZK_data.lua) hold a redundant copy +-- of the value so the radio button shows correctly the moment the menu opens +-- — but on Initialize we override it with the gadget's actual value. +function widget:GetConfigData() + return { + value = options.cabletree_detail.value, + ghosts = options.cabletree_ghosts.value, + } +end + +function widget:SetConfigData(data) + if data and data.value and LEVEL_BY_KEY[data.value] then + options.cabletree_detail.value = data.value + end + if data and type(data.ghosts) == "boolean" then + options.cabletree_ghosts.value = data.ghosts + end +end