From 8ef2e9311ba607222e2c20e35e4bfa69aa769ea0 Mon Sep 17 00:00:00 2001 From: Licho Date: Mon, 13 Apr 2026 01:12:20 +0200 Subject: [PATCH 01/59] cable experiment v1 --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 1014 +++++++++++++++++++++ LuaRules/Images/overdrive/cable.png | Bin 0 -> 362 bytes 2 files changed, 1014 insertions(+) create mode 100644 LuaRules/Gadgets/gfx_overdrive_cables.lua create mode 100644 LuaRules/Images/overdrive/cable.png diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua new file mode 100644 index 0000000000..333313c849 --- /dev/null +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -0,0 +1,1014 @@ +------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------- +-- Overdrive Cable Tree Visualization +-- Maintains a persistent tree that grows organically as pylons are built/destroyed. +-- Cables grow from nearest connected node toward new pylons. +-- Cables wither when pylons are destroyed; orphans reconnect. +-- Per-edge energy flows computed on fully-grown edges only. +------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------- + +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 + +------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------- + +if gadgetHandler:IsSyncedCode() then + +------------------------------------------------------------------------------------- +-- SYNCED +------------------------------------------------------------------------------------- + +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 sqrt = math.sqrt +local max = math.max +local min = math.min +local huge = math.huge + +------------------------------------------------------------------------------------- +-- Config +------------------------------------------------------------------------------------- + +local SEND_PERIOD = 6 +local TICK_PERIOD = 3 -- only tick edges every N frames (not every frame) +local GROWTH_RATE = 250 -- elmos per second +local WITHER_RATE = 400 -- elmos per second +local GAME_SPEED = Game.gameSpeed or 30 +local GROWTH_PER_TICK = GROWTH_RATE / GAME_SPEED * 3 -- adjusted for TICK_PERIOD +local WITHER_PER_TICK = WITHER_RATE / GAME_SPEED * 3 +local MIN_CABLE_CAPACITY = 0.5 + +------------------------------------------------------------------------------------- +-- Unit definitions +------------------------------------------------------------------------------------- + +local pylonDefs = {} +local mexDefs = {} +local generatorDefs = {} + +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 +end + +------------------------------------------------------------------------------------- +-- Persistent tree data per allyTeam +------------------------------------------------------------------------------------- + +local trees = {} +local treeVersion = 0 +local dirty = false + +-- Queue of nodes that just became connected and need orphan scanning. +-- Processed outside of edge iteration to avoid modifying edges during pairs(). +local newlyConnected = {} -- list of {tree, unitID} + +local function InitTree(allyTeamID) + trees[allyTeamID] = { + nodes = {}, + edges = {}, + } +end + +do + local allyTeamList = Spring.GetAllyTeamList() + for i = 1, #allyTeamList do + InitTree(allyTeamList[i]) + end +end + +------------------------------------------------------------------------------------- +-- Helpers +------------------------------------------------------------------------------------- + +local function Dist(x1, z1, x2, z2) + local dx = x1 - x2 + local dz = z1 - z2 + return sqrt(dx * dx + dz * dz) +end + +local function InRange(n1, n2) + return Dist(n1.x, n1.z, n2.x, n2.z) < (n1.range + n2.range) +end + +-- Is a node connected? Iterative (no recursion) to avoid stack overflow. +local function IsConnected(tree, unitID) + local visited = {} + local current = unitID + while current do + if visited[current] then return false end -- cycle detected, bail + visited[current] = true + local node = tree.nodes[current] + if not node then return false end + if node.isRoot then return true end + local edge = tree.edges[current] + if not edge then return false end + if not edge.grown then return false end + current = edge.parentID + end + return false +end + +-- Find root of a node's subtree (follow parent chain up). +local function FindRoot(tree, unitID) + local visited = {} + local current = unitID + while current do + if visited[current] then return current end + visited[current] = true + local node = tree.nodes[current] + if not node then return current end + if node.isRoot then return current end + if not node.parent then return current end + current = node.parent + end + return unitID +end + +-- Find the nearest connected node within range of (x, z, range). +local function FindNearestConnected(tree, x, z, range, excludeID) + local bestID = nil + local bestDist = huge + for uid, node in pairs(tree.nodes) do + if uid ~= excludeID and IsConnected(tree, uid) then + local d = Dist(x, z, node.x, node.z) + if d < (range + node.range) and d < bestDist then + bestDist = d + bestID = uid + end + end + end + return bestID, bestDist +end + +-- Find all nearby nodes in range that belong to DIFFERENT root subtrees. +-- Returns list of {nodeID, rootID} for each unique foreign root. +local function FindNearbyForeignRoots(tree, unitID) + local node = tree.nodes[unitID] + if not node then return {} end + local myRoot = FindRoot(tree, unitID) + local foundRoots = {} -- [rootID] = nearest nodeID in that subtree + local foundDists = {} + + for uid, other in pairs(tree.nodes) do + if uid ~= unitID and InRange(node, other) then + local otherRoot = FindRoot(tree, uid) + if otherRoot ~= myRoot then + local d = Dist(node.x, node.z, other.x, other.z) + if not foundRoots[otherRoot] or d < foundDists[otherRoot] then + foundRoots[otherRoot] = uid + foundDists[otherRoot] = d + end + end + end + end + + local result = {} + for rootID, nearestID in pairs(foundRoots) do + result[#result + 1] = { nodeID = nearestID, rootID = rootID } + end + return result +end + +-- Connect a node to nearby foreign subtrees by creating edges to their roots. +-- Called OUTSIDE of edge iteration. +local function BridgeNearbySubtrees(tree, unitID) + local node = tree.nodes[unitID] + if not node then return end + + local foreignRoots = FindNearbyForeignRoots(tree, unitID) + for i = 1, #foreignRoots do + local rootID = foreignRoots[i].rootID + local rootNode = tree.nodes[rootID] + -- Re-check: root must still be a root and not already have an edge + if rootNode and rootNode.isRoot and not tree.edges[rootID] then + tree.edges[rootID] = { + parentID = unitID, + childID = rootID, + length = max(1, Dist(node.x, node.z, rootNode.x, rootNode.z)), + progress = 0, + grown = false, + withering = false, + } + rootNode.parent = unitID + rootNode.isRoot = false + node.children[rootID] = true + dirty = true + end + end +end + +------------------------------------------------------------------------------------- +-- Node capacity +------------------------------------------------------------------------------------- + +local function GetNodeCapacity(unitID, unitDefID) + if not spValidUnitID(unitID) then return 0, 0 end + local stunned = spGetUnitIsStunned(unitID) or + (spGetUnitRulesParam(unitID, "disarmed") == 1) + if stunned then return 0, 0 end + + local production = 0 + local consumption = 0 + if generatorDefs[unitDefID] then + production = spGetUnitRulesParam(unitID, "current_energyIncome") or 0 + end + if mexDefs[unitDefID] then + consumption = spGetUnitRulesParam(unitID, "overdrive_energyDrain") or 0 + end + return production, consumption +end + +------------------------------------------------------------------------------------- +-- Tree mutations +------------------------------------------------------------------------------------- + +local function OnPylonAdded(allyTeamID, unitID, unitDefID) + local x, _, z = spGetUnitPosition(unitID) + local range = pylonDefs[unitDefID] + if not range then return end + + local tree = trees[allyTeamID] + if tree.nodes[unitID] then return end + + -- Create node + tree.nodes[unitID] = { + x = x, z = z, range = range, unitDefID = unitDefID, + parent = nil, children = {}, isRoot = false, + } + + -- Find nearest node in range (connected or not — we link to nearest in any case) + local bestID = nil + local bestDist = huge + for uid, node in pairs(tree.nodes) do + if uid ~= unitID then + local d = Dist(x, z, node.x, node.z) + if d < (range + node.range) and d < bestDist then + bestDist = d + bestID = uid + end + end + end + + if bestID then + local nearNode = tree.nodes[bestID] + local length = max(1, Dist(x, z, nearNode.x, nearNode.z)) + tree.edges[unitID] = { + parentID = bestID, + childID = unitID, + length = length, + progress = 0, + grown = false, + withering = false, + } + tree.nodes[unitID].parent = bestID + nearNode.children[unitID] = true + -- Also bridge any other subtrees in range + newlyConnected[#newlyConnected + 1] = { tree = tree, unitID = unitID } + else + -- No node in range — become a root + tree.nodes[unitID].isRoot = true + end + + dirty = true +end + +local function OnPylonRemoved(allyTeamID, unitID) + local tree = trees[allyTeamID] + local node = tree.nodes[unitID] + if not node then return end + + -- Collect immediate children + local orphans = {} + for childID, _ in pairs(node.children) do + orphans[#orphans + 1] = childID + end + + -- Remove our parent edge + if node.parent then + local parentNode = tree.nodes[node.parent] + if parentNode then + parentNode.children[unitID] = nil + end + end + tree.edges[unitID] = nil + + -- Remove children's parent edges + for _, childID in ipairs(orphans) do + tree.edges[childID] = nil + local childNode = tree.nodes[childID] + if childNode then + childNode.parent = nil + end + end + + -- Remove node + tree.nodes[unitID] = nil + + -- Reconnect orphans + for _, childID in ipairs(orphans) do + local childNode = tree.nodes[childID] + if childNode then + local nearestID = FindNearestConnected(tree, childNode.x, childNode.z, childNode.range, childID) + if nearestID then + local nearNode = tree.nodes[nearestID] + local length = max(1, Dist(childNode.x, childNode.z, nearNode.x, nearNode.z)) + tree.edges[childID] = { + parentID = nearestID, + childID = childID, + length = length, + progress = 0, + grown = false, + withering = false, + } + childNode.parent = nearestID + childNode.isRoot = false + nearNode.children[childID] = true + else + childNode.isRoot = true + -- This orphan root might be able to adopt other orphans + newlyConnected[#newlyConnected + 1] = { tree = tree, unitID = childID } + end + end + end + + dirty = true +end + +------------------------------------------------------------------------------------- +-- Edge growth tick — called periodically, NOT every frame +------------------------------------------------------------------------------------- + +local function TickEdges() + -- Grow / wither all edges. Collect newly-grown nodes for orphan scanning. + for _, tree in pairs(trees) do + local toRemove = {} + + for childID, edge in pairs(tree.edges) do + if edge.withering then + edge.progress = edge.progress - WITHER_PER_TICK + if edge.progress <= 0 then + toRemove[#toRemove + 1] = childID + end + edge.grown = false + dirty = true + elseif edge.progress < edge.length then + edge.progress = min(edge.length, edge.progress + GROWTH_PER_TICK) + if edge.progress >= edge.length then + edge.grown = true + -- Queue orphan scan (do NOT modify edges here) + newlyConnected[#newlyConnected + 1] = { tree = tree, unitID = childID } + end + dirty = true + end + end + + -- Remove fully-withered edges (after iteration) + for i = 1, #toRemove do + local childID = toRemove[i] + local edge = tree.edges[childID] + if edge then + local parentNode = tree.nodes[edge.parentID] + if parentNode then + parentNode.children[childID] = nil + end + local childNode = tree.nodes[childID] + if childNode then + childNode.parent = nil + childNode.isRoot = true + end + end + tree.edges[childID] = nil + end + end + + -- Process newly connected nodes (adopt nearby orphan roots). + -- This is safe because we're outside the edges iteration. + local batch = newlyConnected + newlyConnected = {} + for i = 1, #batch do + local item = batch[i] + if item.tree.nodes[item.unitID] then + BridgeNearbySubtrees(item.tree, item.unitID) + end + end +end + +------------------------------------------------------------------------------------- +-- Flow computation (post-order DFS on fully-grown edges) +------------------------------------------------------------------------------------- + +local function ComputeSubtreeFlows(tree, unitID, flowResults) + local node = tree.nodes[unitID] + if not node then return 0, 0 end + + local prod, cons = GetNodeCapacity(unitID, node.unitDefID) + + for childID, _ in pairs(node.children) do + local edge = tree.edges[childID] + if edge and edge.grown then + local childProd, childCons = ComputeSubtreeFlows(tree, childID, flowResults) + prod = prod + childProd + cons = cons + childCons + flowResults[childID] = max(childProd, childCons) + end + end + + return prod, cons +end + +local function ComputeFlows(tree) + local flowResults = {} + for unitID, node in pairs(tree.nodes) do + if node.isRoot then + ComputeSubtreeFlows(tree, unitID, flowResults) + end + end + return flowResults +end + +------------------------------------------------------------------------------------- +-- Send state to unsynced +------------------------------------------------------------------------------------- + +local function SendTreesToUnsynced() + for allyTeamID, tree in pairs(trees) do + local flows = ComputeFlows(tree) + + local edgeCount = 0 + local parentXs, parentZs = {}, {} + local childXs, childZs = {}, {} + local progresses, lengths, capacities = {}, {}, {} + + for childID, edge in pairs(tree.edges) do + local parentNode = tree.nodes[edge.parentID] + local childNode = tree.nodes[childID] + if parentNode and childNode and edge.progress > 0 then + edgeCount = edgeCount + 1 + parentXs[edgeCount] = parentNode.x + parentZs[edgeCount] = parentNode.z + childXs[edgeCount] = childNode.x + childZs[edgeCount] = childNode.z + progresses[edgeCount] = edge.progress + lengths[edgeCount] = edge.length + capacities[edgeCount] = flows[childID] or 0 + end + end + + _G.CableTreeData = { + allyTeamID = allyTeamID, + version = treeVersion, + edgeCount = edgeCount, + parentXs = parentXs, parentZs = parentZs, + childXs = childXs, childZs = childZs, + progresses = progresses, lengths = lengths, + capacities = capacities, + } + SendToUnsynced("CableTreeUpdate") + end +end + +------------------------------------------------------------------------------------- +-- GameFrame +------------------------------------------------------------------------------------- + +function gadget:GameFrame(n) + if n % TICK_PERIOD == 0 then + TickEdges() + end + + if dirty and (n % SEND_PERIOD == 0) then + treeVersion = treeVersion + 1 + SendTreesToUnsynced() + dirty = false + end +end + +------------------------------------------------------------------------------------- +-- Unit lifecycle +------------------------------------------------------------------------------------- + +function gadget:UnitCreated(unitID, unitDefID, unitTeam) + if not pylonDefs[unitDefID] then return end + OnPylonAdded(spGetUnitAllyTeam(unitID), unitID, unitDefID) +end + +function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) + if not pylonDefs[unitDefID] then return end + OnPylonRemoved(spGetUnitAllyTeam(unitID), unitID) +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 newAlly ~= oldAlly then + OnPylonRemoved(oldAlly, unitID) + OnPylonAdded(newAlly, unitID, unitDefID) + end +end + +function gadget:Initialize() + GG.CableTree = trees + for _, unitID in ipairs(Spring.GetAllUnits()) do + local unitDefID = spGetUnitDefID(unitID) + if unitDefID and pylonDefs[unitDefID] then + OnPylonAdded(spGetUnitAllyTeam(unitID), unitID, unitDefID) + end + end + -- Process any queued orphan adoptions from init + if #newlyConnected > 0 then + local batch = newlyConnected + newlyConnected = {} + for i = 1, #batch do + local item = batch[i] + if item.tree.nodes[item.unitID] then + AdoptNearbyOrphans(item.tree, item.unitID) + end + end + end +end + +------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------- + +else -- UNSYNCED + +------------------------------------------------------------------------------------- +-- UNSYNCED — Renders cable edges onto ground texture +------------------------------------------------------------------------------------- + +local glTexture = gl.Texture +local glCreateTexture = gl.CreateTexture +local glColor = gl.Color +local glTexRect = gl.TexRect +local glResetState = gl.ResetState +local glResetMatrices = gl.ResetMatrices +local glVertex = gl.Vertex +local glTexCoord = gl.TexCoord + +local spGetMapSquareTexture = Spring.GetMapSquareTexture +local spSetMapSquareTexture = Spring.SetMapSquareTexture +local spGetMyAllyTeamID = Spring.GetMyAllyTeamID +local spGetSpectatingState = Spring.GetSpectatingState + +local floor = math.floor +local sqrt = math.sqrt +local max = math.max +local min = math.min + +local MAP_WIDTH = Game.mapSizeX +local MAP_HEIGHT = Game.mapSizeZ +local SQUARE_SIZE = 1024 +local SQUARES_X = MAP_WIDTH / SQUARE_SIZE +local SQUARES_Z = MAP_HEIGHT / SQUARE_SIZE + +local CABLE_TEXTURE = "LuaRules/Images/overdrive/cable.png" +local CABLE_TEX_HEIGHT = 64 + +local MIN_CABLE_WIDTH = 6 +local MAX_CABLE_WIDTH = 28 +local MAX_CAPACITY_REF = 100 + +------------------------------------------------------------------------------------- +-- State +------------------------------------------------------------------------------------- + +local squareFBOs = {} +local renderEdges = {} +local edgesByAllyTeam = {} +local lastVersions = {} +local needsRedraw = false +local drawnSquares = {} + +------------------------------------------------------------------------------------- +-- FBO management +------------------------------------------------------------------------------------- + +local function GetSquareFBO(sx, sz) + if not squareFBOs[sx] then squareFBOs[sx] = {} end + if squareFBOs[sx][sz] then return squareFBOs[sx][sz] end + local fbo = glCreateTexture(SQUARE_SIZE, SQUARE_SIZE, { + wrap_s = GL.CLAMP_TO_EDGE, wrap_t = GL.CLAMP_TO_EDGE, + fbo = true, min_filter = GL.LINEAR_MIPMAP_NEAREST, + }) + if not fbo then return nil end + squareFBOs[sx][sz] = fbo + return fbo +end + +local function SquareKey(sx, sz) + return sx * 10000 + sz +end + +------------------------------------------------------------------------------------- +-- Draw textured cable onto FBO +------------------------------------------------------------------------------------- + +local function DrawCableOnSquare(sx, sz, fbo, x1, z1, x2, z2, width) + local sqX = sx * SQUARE_SIZE + local sqZ = sz * SQUARE_SIZE + local dx = x2 - x1 + local dz = z2 - z1 + local len = sqrt(dx * dx + dz * dz) + if len < 1 then return end + + local hw = width * 0.5 + local px = -dz / len * hw + local pz = dx / len * hw + + local c1x, c1z = x1 - px, z1 - pz + local c2x, c2z = x1 + px, z1 + pz + local c3x, c3z = x2 + px, z2 + pz + local c4x, c4z = x2 - px, z2 - pz + + local function w2t(wx, wz) + return 2 * (wx - sqX) / SQUARE_SIZE - 1, + 2 * (wz - sqZ) / SQUARE_SIZE - 1 + end + + local vTile = len / CABLE_TEX_HEIGHT + local t1x, t1z = w2t(c1x, c1z) + local t2x, t2z = w2t(c2x, c2z) + local t3x, t3z = w2t(c3x, c3z) + local t4x, t4z = w2t(c4x, c4z) + + gl.RenderToTexture(fbo, function() + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + glColor(1, 1, 1, 1) + gl.BeginEnd(GL.QUADS, function() + glTexCoord(0, 0) glVertex(t1x, t1z, 0) + glTexCoord(1, 0) glVertex(t2x, t2z, 0) + glTexCoord(1, vTile) glVertex(t3x, t3z, 0) + glTexCoord(0, vTile) glVertex(t4x, t4z, 0) + end) + end) +end + +------------------------------------------------------------------------------------- +-- Cable width from capacity +------------------------------------------------------------------------------------- + +local function GetCableWidth(capacity) + local t = min(1, capacity / MAX_CAPACITY_REF) + return MIN_CABLE_WIDTH + t * t * (MAX_CABLE_WIDTH - MIN_CABLE_WIDTH) +end + +------------------------------------------------------------------------------------- +-- Full redraw +------------------------------------------------------------------------------------- + +local function RedrawCables() + glResetState() + glResetMatrices() + + -- Revert previous squares + for _, sq in pairs(drawnSquares) do + spSetMapSquareTexture(sq.sx, sq.sz, "") + end + + -- Build draw list with growth interpolation + local drawList = {} + for i = 1, #renderEdges do + local e = renderEdges[i] + if e.length > 0 then + local frac = min(1, e.progress / e.length) + if frac > 0.01 then + local ex = e.px + frac * (e.cx - e.px) + local ez = e.pz + frac * (e.cz - e.pz) + drawList[#drawList + 1] = { + x1 = e.px, z1 = e.pz, x2 = ex, z2 = ez, + capacity = e.capacity, + } + end + end + end + + if #drawList == 0 then + needsRedraw = false + return + end + + -- Determine needed squares + local neededSquares = {} + for i = 1, #drawList do + local c = drawList[i] + -- Use generous margin — 100 elmos + local m = 100 + local minSx = max(0, floor((min(c.x1, c.x2) - m) / SQUARE_SIZE)) + local maxSx = min(SQUARES_X - 1, floor((max(c.x1, c.x2) + m) / SQUARE_SIZE)) + local minSz = max(0, floor((min(c.z1, c.z2) - m) / SQUARE_SIZE)) + local maxSz = min(SQUARES_Z - 1, floor((max(c.z1, c.z2) + m) / SQUARE_SIZE)) + for sx = minSx, maxSx do + for sz = minSz, maxSz do + neededSquares[SquareKey(sx, sz)] = { sx = sx, sz = sz } + end + end + end + + Spring.Echo("[CableTree][U] Drawing " .. #drawList .. " cables on " .. (function() local n=0; for _ in pairs(neededSquares) do n=n+1 end; return n end)() .. " squares") + + -- Snapshot current map texture into FBOs then draw cables + drawnSquares = {} + for key, sq in pairs(neededSquares) do + local fbo = GetSquareFBO(sq.sx, sq.sz) + if not fbo then + Spring.Echo("[CableTree][U] FBO creation failed for " .. sq.sx .. "," .. sq.sz) + else + -- Snapshot current texture + spGetMapSquareTexture(sq.sx, sq.sz, 0, fbo) + + -- TEST: Draw a big bright red X across the entire square to verify FBO pipeline + gl.RenderToTexture(fbo, function() + gl.Texture(false) + glColor(1, 0, 0, 1) + -- Big diagonal cross filling 80% of the square + gl.BeginEnd(GL.QUADS, function() + -- Horizontal bar across center + glVertex(-0.8, -0.05, 0) + glVertex( 0.8, -0.05, 0) + glVertex( 0.8, 0.05, 0) + glVertex(-0.8, 0.05, 0) + -- Vertical bar across center + glVertex(-0.05, -0.8, 0) + glVertex( 0.05, -0.8, 0) + glVertex( 0.05, 0.8, 0) + glVertex(-0.05, 0.8, 0) + end) + glColor(1, 1, 1, 1) + end) + + gl.GenerateMipmap(fbo) + spSetMapSquareTexture(sq.sx, sq.sz, fbo) + drawnSquares[key] = sq + end + end + + needsRedraw = false +end + +------------------------------------------------------------------------------------- +-- Receive data from synced +------------------------------------------------------------------------------------- + +local function OnCableTreeUpdate() + local data = SYNCED.CableTreeData + if not data then return end + + local spec, fullview = spGetSpectatingState() + local myAllyTeam = spGetMyAllyTeamID() + local allyTeamID = data.allyTeamID + + if not (spec or fullview) and allyTeamID ~= myAllyTeam then return end + if lastVersions[allyTeamID] and data.version == lastVersions[allyTeamID] then return end + lastVersions[allyTeamID] = data.version + + local edges = {} + local count = data.edgeCount or 0 + for i = 1, count do + edges[i] = { + px = data.parentXs[i], pz = data.parentZs[i], + cx = data.childXs[i], cz = data.childZs[i], + progress = data.progresses[i], length = data.lengths[i], + capacity = data.capacities[i], + } + end + edgesByAllyTeam[allyTeamID] = edges + + renderEdges = {} + for _, teamEdges in pairs(edgesByAllyTeam) do + for j = 1, #teamEdges do + renderEdges[#renderEdges + 1] = teamEdges[j] + end + end + + needsRedraw = true +end + +------------------------------------------------------------------------------------- +-- Drawing hook +------------------------------------------------------------------------------------- + +local drawCount = 0 + +function gadget:DrawGenesis() + if not needsRedraw then return end + if #renderEdges == 0 then + needsRedraw = false + return + end + + drawCount = drawCount + 1 + + glResetState() + glResetMatrices() + + -- Revert previously modified squares + for _, sq in pairs(drawnSquares) do + spSetMapSquareTexture(sq.sx, sq.sz, "") + end + + -- Build draw list with growth interpolation + local drawList = {} + for i = 1, #renderEdges do + local e = renderEdges[i] + if e.length > 0 then + local frac = min(1, e.progress / e.length) + if frac > 0.01 then + drawList[#drawList + 1] = { + x1 = e.px, z1 = e.pz, + x2 = e.px + frac * (e.cx - e.px), + z2 = e.pz + frac * (e.cz - e.pz), + capacity = e.capacity, + } + end + end + end + + if #drawList == 0 then + needsRedraw = false + return + end + + -- Collect all squares that need updating + local neededSquares = {} + for i = 1, #drawList do + local c = drawList[i] + local m = 100 + local minSx = max(0, floor((min(c.x1, c.x2) - m) / SQUARE_SIZE)) + local maxSx = min(SQUARES_X - 1, floor((max(c.x1, c.x2) + m) / SQUARE_SIZE)) + local minSz = max(0, floor((min(c.z1, c.z2) - m) / SQUARE_SIZE)) + local maxSz = min(SQUARES_Z - 1, floor((max(c.z1, c.z2) + m) / SQUARE_SIZE)) + for sx = minSx, maxSx do + for sz = minSz, maxSz do + neededSquares[SquareKey(sx, sz)] = { sx = sx, sz = sz } + end + end + end + + -- For each square: create orig+cur pair, snapshot, draw cables, apply + -- Using the exact pattern proven by terrain_texture_handler + drawnSquares = {} + for key, sq in pairs(neededSquares) do + local sx, sz = sq.sx, sq.sz + + -- Get or create the FBO pair for this square + if not squareFBOs[sx] then squareFBOs[sx] = {} end + if not squareFBOs[sx][sz] then + local cur = glCreateTexture(SQUARE_SIZE, SQUARE_SIZE, { + fbo = true, min_filter = GL.LINEAR_MIPMAP_NEAREST, + wrap_s = GL.CLAMP_TO_EDGE, wrap_t = GL.CLAMP_TO_EDGE, + }) + local orig = glCreateTexture(SQUARE_SIZE, SQUARE_SIZE, { + fbo = true, + wrap_s = GL.CLAMP_TO_EDGE, wrap_t = GL.CLAMP_TO_EDGE, + }) + if cur and orig then + squareFBOs[sx][sz] = { cur = cur, orig = orig } + else + if cur then gl.DeleteTextureFBO(cur) end + if orig then gl.DeleteTextureFBO(orig) end + end + end + + local pair = squareFBOs[sx] and squareFBOs[sx][sz] + if pair then + -- Snapshot: capture current map texture into orig + spGetMapSquareTexture(sx, sz, 0, pair.orig) + + -- Copy orig → cur + glTexture(pair.orig) + gl.RenderToTexture(pair.cur, function() + glTexRect(-1, 1, 1, -1) + end) + glTexture(false) + + -- Draw cables onto cur: border + glowing core + local sqX = sx * SQUARE_SIZE + local sqZ = sz * SQUARE_SIZE + + local function w2t(wx, wz) + return 2 * (wx - sqX) / SQUARE_SIZE - 1, + 2 * (wz - sqZ) / SQUARE_SIZE - 1 + end + + local function drawQuad(x1, z1, x2, z2, hw, dx, dz, len) + local px = -dz / len * hw + local pz = dx / len * hw + local t1x, t1z = w2t(x1 - px, z1 - pz) + local t2x, t2z = w2t(x1 + px, z1 + pz) + local t3x, t3z = w2t(x2 + px, z2 + pz) + local t4x, t4z = w2t(x2 - px, z2 - pz) + glVertex(t1x, t1z, 0) + glVertex(t2x, t2z, 0) + glVertex(t3x, t3z, 0) + glVertex(t4x, t4z, 0) + end + + gl.RenderToTexture(pair.cur, function() + gl.Texture(false) + + -- Pass 1: dark border (full width) + gl.BeginEnd(GL.QUADS, function() + for i = 1, #drawList do + local c = drawList[i] + local cdx = c.x2 - c.x1 + local cdz = c.z2 - c.z1 + local clen = sqrt(cdx * cdx + cdz * cdz) + if clen > 1 then + local w = GetCableWidth(c.capacity) + glColor(0.04, 0.08, 0.12, 0.9) + drawQuad(c.x1, c.z1, c.x2, c.z2, w * 0.5, cdx, cdz, clen) + end + end + end) + + -- Pass 2: glowing inner core (60% width, color by capacity) + gl.BeginEnd(GL.QUADS, function() + for i = 1, #drawList do + local c = drawList[i] + local cdx = c.x2 - c.x1 + local cdz = c.z2 - c.z1 + local clen = sqrt(cdx * cdx + cdz * cdz) + if clen > 1 then + local w = GetCableWidth(c.capacity) + -- Color: blue for low capacity, bright cyan for high + local t = min(1, c.capacity / MAX_CAPACITY_REF) + local r = 0.05 + t * 0.15 + local g = 0.3 + t * 0.5 + local b = 0.7 + t * 0.3 + glColor(r, g, b, 0.95) + drawQuad(c.x1, c.z1, c.x2, c.z2, w * 0.3, cdx, cdz, clen) + end + end + end) + + glColor(1, 1, 1, 1) + end) + + -- Apply + gl.GenerateMipmap(pair.cur) + spSetMapSquareTexture(sx, sz, pair.cur) + drawnSquares[key] = sq + end + end + + needsRedraw = false +end + +------------------------------------------------------------------------------------- +-- Lifecycle +------------------------------------------------------------------------------------- + +function gadget:Initialize() + if not gl.RenderToTexture then + gadgetHandler:RemoveGadget() + return + end + gadgetHandler:AddSyncAction("CableTreeUpdate", OnCableTreeUpdate) +end + +function gadget:Shutdown() + for _, sq in pairs(drawnSquares) do + spSetMapSquareTexture(sq.sx, sq.sz, "") + end + for sx, szMap in pairs(squareFBOs) do + for sz, pair in pairs(szMap) do + if pair.cur then gl.DeleteTextureFBO(pair.cur) end + if pair.orig then gl.DeleteTextureFBO(pair.orig) end + end + end + squareFBOs = {} + drawnSquares = {} + gadgetHandler:RemoveSyncAction("CableTreeUpdate") +end + +end -- UNSYNCED diff --git a/LuaRules/Images/overdrive/cable.png b/LuaRules/Images/overdrive/cable.png new file mode 100644 index 0000000000000000000000000000000000000000..273df3275a1b518058da0fad2efd7a2731f8e882 GIT binary patch literal 362 zcmV-w0hRuVP);$JcRKACb}>&vH>(zOG;@0L#yb5bo&BZf+lW*<|aw z!Mduk%MFTR^-|;omiZF%$NTR4YExzcWlS4_!>AiLy|~0Injwrr9EDR%Mh1avW8&J| z93AY}9DmkWloh_pYL{0Vyrpxbaf&2P@Nje0&HYH8pw zO+ Date: Mon, 13 Apr 2026 01:45:41 +0200 Subject: [PATCH 02/59] more grid experiments --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 1091 ++++++++++----------- 1 file changed, 507 insertions(+), 584 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 333313c849..60bc95d23f 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -27,6 +27,9 @@ if gadgetHandler:IsSyncedCode() then ------------------------------------------------------------------------------------- -- SYNCED +-- Reads gridNumber from unit_mex_overdrive as source of truth. +-- Periodically computes desired spanning tree edges per grid. +-- Diffs against current edges to produce grow/wither animations. ------------------------------------------------------------------------------------- local spGetUnitPosition = Spring.GetUnitPosition @@ -39,20 +42,19 @@ local spValidUnitID = Spring.ValidUnitID local sqrt = math.sqrt local max = math.max local min = math.min -local huge = math.huge ------------------------------------------------------------------------------------- -- Config ------------------------------------------------------------------------------------- +local SYNC_PERIOD = 30 -- frames between grid sync (~1/s) +local TICK_PERIOD = 3 local SEND_PERIOD = 6 -local TICK_PERIOD = 3 -- only tick edges every N frames (not every frame) -local GROWTH_RATE = 250 -- elmos per second -local WITHER_RATE = 400 -- elmos per second +local GROWTH_RATE = 250 -- elmos/s +local WITHER_RATE = 400 local GAME_SPEED = Game.gameSpeed or 30 -local GROWTH_PER_TICK = GROWTH_RATE / GAME_SPEED * 3 -- adjusted for TICK_PERIOD -local WITHER_PER_TICK = WITHER_RATE / GAME_SPEED * 3 -local MIN_CABLE_CAPACITY = 0.5 +local GROWTH_PER_TICK = GROWTH_RATE / GAME_SPEED * TICK_PERIOD +local WITHER_PER_TICK = WITHER_RATE / GAME_SPEED * TICK_PERIOD ------------------------------------------------------------------------------------- -- Unit definitions @@ -80,28 +82,26 @@ for i = 1, #UnitDefs do end ------------------------------------------------------------------------------------- --- Persistent tree data per allyTeam +-- State ------------------------------------------------------------------------------------- -local trees = {} -local treeVersion = 0 -local dirty = false +-- All tracked pylons per allyTeam: nodes[allyTeamID][unitID] = {x, z, range, unitDefID} +local nodes = {} --- Queue of nodes that just became connected and need orphan scanning. --- Processed outside of edge iteration to avoid modifying edges during pairs(). -local newlyConnected = {} -- list of {tree, unitID} +-- Current animated edges: edges[edgeKey] = {parentID, childID, px, pz, cx, cz, length, progress, withering} +-- edgeKey = parentID .. ":" .. childID (string) +local edges = {} -local function InitTree(allyTeamID) - trees[allyTeamID] = { - nodes = {}, - edges = {}, - } -end +-- Desired edges from last grid sync: desiredEdges[edgeKey] = true +local desiredEdges = {} + +local treeVersion = 0 +local dirty = false do local allyTeamList = Spring.GetAllyTeamList() for i = 1, #allyTeamList do - InitTree(allyTeamList[i]) + nodes[allyTeamList[i]] = {} end end @@ -115,129 +115,22 @@ local function Dist(x1, z1, x2, z2) return sqrt(dx * dx + dz * dz) end -local function InRange(n1, n2) - return Dist(n1.x, n1.z, n2.x, n2.z) < (n1.range + n2.range) -end - --- Is a node connected? Iterative (no recursion) to avoid stack overflow. -local function IsConnected(tree, unitID) - local visited = {} - local current = unitID - while current do - if visited[current] then return false end -- cycle detected, bail - visited[current] = true - local node = tree.nodes[current] - if not node then return false end - if node.isRoot then return true end - local edge = tree.edges[current] - if not edge then return false end - if not edge.grown then return false end - current = edge.parentID - end - return false -end - --- Find root of a node's subtree (follow parent chain up). -local function FindRoot(tree, unitID) - local visited = {} - local current = unitID - while current do - if visited[current] then return current end - visited[current] = true - local node = tree.nodes[current] - if not node then return current end - if node.isRoot then return current end - if not node.parent then return current end - current = node.parent - end - return unitID -end - --- Find the nearest connected node within range of (x, z, range). -local function FindNearestConnected(tree, x, z, range, excludeID) - local bestID = nil - local bestDist = huge - for uid, node in pairs(tree.nodes) do - if uid ~= excludeID and IsConnected(tree, uid) then - local d = Dist(x, z, node.x, node.z) - if d < (range + node.range) and d < bestDist then - bestDist = d - bestID = uid - end - end - end - return bestID, bestDist -end - --- Find all nearby nodes in range that belong to DIFFERENT root subtrees. --- Returns list of {nodeID, rootID} for each unique foreign root. -local function FindNearbyForeignRoots(tree, unitID) - local node = tree.nodes[unitID] - if not node then return {} end - local myRoot = FindRoot(tree, unitID) - local foundRoots = {} -- [rootID] = nearest nodeID in that subtree - local foundDists = {} - - for uid, other in pairs(tree.nodes) do - if uid ~= unitID and InRange(node, other) then - local otherRoot = FindRoot(tree, uid) - if otherRoot ~= myRoot then - local d = Dist(node.x, node.z, other.x, other.z) - if not foundRoots[otherRoot] or d < foundDists[otherRoot] then - foundRoots[otherRoot] = uid - foundDists[otherRoot] = d - end - end - end - end - - local result = {} - for rootID, nearestID in pairs(foundRoots) do - result[#result + 1] = { nodeID = nearestID, rootID = rootID } - end - return result -end - --- Connect a node to nearby foreign subtrees by creating edges to their roots. --- Called OUTSIDE of edge iteration. -local function BridgeNearbySubtrees(tree, unitID) - local node = tree.nodes[unitID] - if not node then return end - - local foreignRoots = FindNearbyForeignRoots(tree, unitID) - for i = 1, #foreignRoots do - local rootID = foreignRoots[i].rootID - local rootNode = tree.nodes[rootID] - -- Re-check: root must still be a root and not already have an edge - if rootNode and rootNode.isRoot and not tree.edges[rootID] then - tree.edges[rootID] = { - parentID = unitID, - childID = rootID, - length = max(1, Dist(node.x, node.z, rootNode.x, rootNode.z)), - progress = 0, - grown = false, - withering = false, - } - rootNode.parent = unitID - rootNode.isRoot = false - node.children[rootID] = true - dirty = true - end +local function EdgeKey(parentID, childID) + -- Canonical key: smaller ID first to avoid duplicates + if parentID < childID then + return parentID .. ":" .. childID + else + return childID .. ":" .. parentID end end -------------------------------------------------------------------------------------- --- Node capacity -------------------------------------------------------------------------------------- - local function GetNodeCapacity(unitID, unitDefID) if not spValidUnitID(unitID) then return 0, 0 end local stunned = spGetUnitIsStunned(unitID) or (spGetUnitRulesParam(unitID, "disarmed") == 1) if stunned then return 0, 0 end - local production = 0 - local consumption = 0 + local production, consumption = 0, 0 if generatorDefs[unitDefID] then production = spGetUnitRulesParam(unitID, "current_energyIncome") or 0 end @@ -248,210 +141,221 @@ local function GetNodeCapacity(unitID, unitDefID) end ------------------------------------------------------------------------------------- --- Tree mutations +-- Grid sync: read gridNumber, compute spanning trees, diff edges ------------------------------------------------------------------------------------- -local function OnPylonAdded(allyTeamID, unitID, unitDefID) - local x, _, z = spGetUnitPosition(unitID) - local range = pylonDefs[unitDefID] - if not range then return end +local function SyncWithGrid() + local newDesired = {} - local tree = trees[allyTeamID] - if tree.nodes[unitID] then return end - - -- Create node - tree.nodes[unitID] = { - x = x, z = z, range = range, unitDefID = unitDefID, - parent = nil, children = {}, isRoot = false, - } - - -- Find nearest node in range (connected or not — we link to nearest in any case) - local bestID = nil - local bestDist = huge - for uid, node in pairs(tree.nodes) do - if uid ~= unitID then - local d = Dist(x, z, node.x, node.z) - if d < (range + node.range) and d < bestDist then - bestDist = d - bestID = uid + for allyTeamID, allyNodes in pairs(nodes) do + -- Group pylons by gridNumber + local pylonsByGrid = {} -- [gridID] = { {unitID, x, z, range, unitDefID}, ... } + for unitID, node in pairs(allyNodes) do + if spValidUnitID(unitID) then + local gridID = spGetUnitRulesParam(unitID, "gridNumber") or 0 + if gridID > 0 then + if not pylonsByGrid[gridID] then + pylonsByGrid[gridID] = {} + end + local list = pylonsByGrid[gridID] + list[#list + 1] = { + unitID = unitID, + x = node.x, z = node.z, + range = node.range, + unitDefID = node.unitDefID, + } + end end end - end - if bestID then - local nearNode = tree.nodes[bestID] - local length = max(1, Dist(x, z, nearNode.x, nearNode.z)) - tree.edges[unitID] = { - parentID = bestID, - childID = unitID, - length = length, - progress = 0, - grown = false, - withering = false, - } - tree.nodes[unitID].parent = bestID - nearNode.children[unitID] = true - -- Also bridge any other subtrees in range - newlyConnected[#newlyConnected + 1] = { tree = tree, unitID = unitID } - else - -- No node in range — become a root - tree.nodes[unitID].isRoot = true - end + -- For each grid, build minimum spanning tree via Prim's algorithm. + -- Each node always connects to its nearest already-connected neighbor. + for gridID, pylons in pairs(pylonsByGrid) do + if #pylons >= 2 then + -- Pick root: highest production + local bestRoot = 1 + local bestProd = -1 + for i = 1, #pylons do + local prod = GetNodeCapacity(pylons[i].unitID, pylons[i].unitDefID) + if prod > bestProd then + bestProd = prod + bestRoot = i + end + end - dirty = true -end + local inTree = {} + inTree[bestRoot] = true + local treeSize = 1 + + while treeSize < #pylons do + local bestDistSq = math.huge + local bestJ = nil + local bestParentIdx = nil + + for j = 1, #pylons do + if not inTree[j] then + local other = pylons[j] + for k = 1, #pylons do + if inTree[k] then + local curr = pylons[k] + local dx = curr.x - other.x + local dz = curr.z - other.z + local distSq = dx * dx + dz * dz + local combinedRange = curr.range + other.range + if distSq < combinedRange * combinedRange and distSq < bestDistSq then + bestDistSq = distSq + bestJ = j + bestParentIdx = k + end + end + end + end + end -local function OnPylonRemoved(allyTeamID, unitID) - local tree = trees[allyTeamID] - local node = tree.nodes[unitID] - if not node then return end + if not bestJ then break end - -- Collect immediate children - local orphans = {} - for childID, _ in pairs(node.children) do - orphans[#orphans + 1] = childID - end + inTree[bestJ] = true + treeSize = treeSize + 1 - -- Remove our parent edge - if node.parent then - local parentNode = tree.nodes[node.parent] - if parentNode then - parentNode.children[unitID] = nil + local parent = pylons[bestParentIdx] + local child = pylons[bestJ] + local key = EdgeKey(parent.unitID, child.unitID) + newDesired[key] = { + parentID = parent.unitID, + childID = child.unitID, + px = parent.x, pz = parent.z, + cx = child.x, cz = child.z, + } + end + end end end - tree.edges[unitID] = nil - -- Remove children's parent edges - for _, childID in ipairs(orphans) do - tree.edges[childID] = nil - local childNode = tree.nodes[childID] - if childNode then - childNode.parent = nil + -- Diff: find new edges, removed edges, unchanged edges + -- New edges: in newDesired but not in desiredEdges → create growing edge + for key, info in pairs(newDesired) do + if not edges[key] then + local length = max(1, Dist(info.px, info.pz, info.cx, info.cz)) + edges[key] = { + parentID = info.parentID, + childID = info.childID, + px = info.px, pz = info.pz, + cx = info.cx, cz = info.cz, + length = length, + progress = 0, + withering = false, + } + dirty = true + elseif edges[key].withering then + -- Was withering, now wanted again — reverse direction + edges[key].withering = false + dirty = true end end - -- Remove node - tree.nodes[unitID] = nil - - -- Reconnect orphans - for _, childID in ipairs(orphans) do - local childNode = tree.nodes[childID] - if childNode then - local nearestID = FindNearestConnected(tree, childNode.x, childNode.z, childNode.range, childID) - if nearestID then - local nearNode = tree.nodes[nearestID] - local length = max(1, Dist(childNode.x, childNode.z, nearNode.x, nearNode.z)) - tree.edges[childID] = { - parentID = nearestID, - childID = childID, - length = length, - progress = 0, - grown = false, - withering = false, - } - childNode.parent = nearestID - childNode.isRoot = false - nearNode.children[childID] = true - else - childNode.isRoot = true - -- This orphan root might be able to adopt other orphans - newlyConnected[#newlyConnected + 1] = { tree = tree, unitID = childID } - end + -- Removed edges: in desiredEdges but not in newDesired → start withering + for key, _ in pairs(desiredEdges) do + if not newDesired[key] and edges[key] and not edges[key].withering then + edges[key].withering = true + dirty = true end end - dirty = true + desiredEdges = newDesired end ------------------------------------------------------------------------------------- --- Edge growth tick — called periodically, NOT every frame +-- Edge growth / wither tick ------------------------------------------------------------------------------------- local function TickEdges() - -- Grow / wither all edges. Collect newly-grown nodes for orphan scanning. - for _, tree in pairs(trees) do - local toRemove = {} - - for childID, edge in pairs(tree.edges) do - if edge.withering then - edge.progress = edge.progress - WITHER_PER_TICK - if edge.progress <= 0 then - toRemove[#toRemove + 1] = childID - end - edge.grown = false - dirty = true - elseif edge.progress < edge.length then - edge.progress = min(edge.length, edge.progress + GROWTH_PER_TICK) - if edge.progress >= edge.length then - edge.grown = true - -- Queue orphan scan (do NOT modify edges here) - newlyConnected[#newlyConnected + 1] = { tree = tree, unitID = childID } - end - dirty = true - end - end + local toRemove = {} - -- Remove fully-withered edges (after iteration) - for i = 1, #toRemove do - local childID = toRemove[i] - local edge = tree.edges[childID] - if edge then - local parentNode = tree.nodes[edge.parentID] - if parentNode then - parentNode.children[childID] = nil - end - local childNode = tree.nodes[childID] - if childNode then - childNode.parent = nil - childNode.isRoot = true - end + for key, edge in pairs(edges) do + if edge.withering then + edge.progress = edge.progress - WITHER_PER_TICK + if edge.progress <= 0 then + toRemove[#toRemove + 1] = key end - tree.edges[childID] = nil + dirty = true + elseif edge.progress < edge.length then + edge.progress = min(edge.length, edge.progress + GROWTH_PER_TICK) + dirty = true end end - -- Process newly connected nodes (adopt nearby orphan roots). - -- This is safe because we're outside the edges iteration. - local batch = newlyConnected - newlyConnected = {} - for i = 1, #batch do - local item = batch[i] - if item.tree.nodes[item.unitID] then - BridgeNearbySubtrees(item.tree, item.unitID) - end + for i = 1, #toRemove do + edges[toRemove[i]] = nil end end ------------------------------------------------------------------------------------- --- Flow computation (post-order DFS on fully-grown edges) +-- Flow computation: per-edge capacity via post-order DFS on spanning tree ------------------------------------------------------------------------------------- -local function ComputeSubtreeFlows(tree, unitID, flowResults) - local node = tree.nodes[unitID] - if not node then return 0, 0 end +local function ComputeFlows() + -- Rebuild tree structure for DFS from edges + local children = {} -- [parentID] = { childID, ... } + local roots = {} -- [unitID] = true + local parentOf = {} -- [childID] = parentID + local edgeByChild = {} -- [childID] = edgeKey - local prod, cons = GetNodeCapacity(unitID, node.unitDefID) + -- Collect all participating nodes + local nodeSet = {} + for key, edge in pairs(edges) do + if edge.progress >= edge.length and not edge.withering then + -- Fully grown edge — use BFS parent→child direction from desired edges + local info = desiredEdges[key] + if info then + local pid, cid = info.parentID, info.childID + if not children[pid] then children[pid] = {} end + children[pid][#children[pid] + 1] = cid + parentOf[cid] = pid + edgeByChild[cid] = key + nodeSet[pid] = true + nodeSet[cid] = true + end + end + end - for childID, _ in pairs(node.children) do - local edge = tree.edges[childID] - if edge and edge.grown then - local childProd, childCons = ComputeSubtreeFlows(tree, childID, flowResults) - prod = prod + childProd - cons = cons + childCons - flowResults[childID] = max(childProd, childCons) + -- Find roots (nodes with no parent) + for uid, _ in pairs(nodeSet) do + if not parentOf[uid] then + roots[uid] = true end end - return prod, cons -end + -- Post-order DFS + local flowResults = {} -- [edgeKey] = capacity + local function dfs(uid) + local prod, cons = 0, 0 + -- Get this node's capacity from any allyTeam + for _, allyNodes in pairs(nodes) do + local node = allyNodes[uid] + if node then + local p, c = GetNodeCapacity(uid, node.unitDefID) + prod = prod + p + cons = cons + c + break + end + end -local function ComputeFlows(tree) - local flowResults = {} - for unitID, node in pairs(tree.nodes) do - if node.isRoot then - ComputeSubtreeFlows(tree, unitID, flowResults) + if children[uid] then + for i = 1, #children[uid] do + local cid = children[uid][i] + local cProd, cCons = dfs(cid) + prod = prod + cProd + cons = cons + cCons + flowResults[edgeByChild[cid]] = max(cProd, cCons) + end end + return prod, cons end + + for uid, _ in pairs(roots) do + dfs(uid) + end + return flowResults end @@ -459,32 +363,32 @@ end -- Send state to unsynced ------------------------------------------------------------------------------------- -local function SendTreesToUnsynced() - for allyTeamID, tree in pairs(trees) do - local flows = ComputeFlows(tree) - - local edgeCount = 0 - local parentXs, parentZs = {}, {} - local childXs, childZs = {}, {} - local progresses, lengths, capacities = {}, {}, {} - - for childID, edge in pairs(tree.edges) do - local parentNode = tree.nodes[edge.parentID] - local childNode = tree.nodes[childID] - if parentNode and childNode and edge.progress > 0 then - edgeCount = edgeCount + 1 - parentXs[edgeCount] = parentNode.x - parentZs[edgeCount] = parentNode.z - childXs[edgeCount] = childNode.x - childZs[edgeCount] = childNode.z - progresses[edgeCount] = edge.progress - lengths[edgeCount] = edge.length - capacities[edgeCount] = flows[childID] or 0 - end +local function SendToUnsyncedAll() + local flows = ComputeFlows() + + local edgeCount = 0 + local parentXs, parentZs = {}, {} + local childXs, childZs = {}, {} + local progresses, lengths, capacities = {}, {}, {} + local allyTeamID = 0 -- we send all edges in one batch + + for key, edge in pairs(edges) do + if edge.progress > 0 then + edgeCount = edgeCount + 1 + parentXs[edgeCount] = edge.px + parentZs[edgeCount] = edge.pz + childXs[edgeCount] = edge.cx + childZs[edgeCount] = edge.cz + progresses[edgeCount] = edge.progress + lengths[edgeCount] = edge.length + capacities[edgeCount] = flows[key] or 0 end + end + -- Send for each allyTeam that has nodes (spectators see all) + for atID, _ in pairs(nodes) do _G.CableTreeData = { - allyTeamID = allyTeamID, + allyTeamID = atID, version = treeVersion, edgeCount = edgeCount, parentXs = parentXs, parentZs = parentZs, @@ -501,29 +405,42 @@ end ------------------------------------------------------------------------------------- function gadget:GameFrame(n) + if n % SYNC_PERIOD == 2 then + SyncWithGrid() + end + if n % TICK_PERIOD == 0 then TickEdges() end if dirty and (n % SEND_PERIOD == 0) then treeVersion = treeVersion + 1 - SendTreesToUnsynced() + SendToUnsyncedAll() dirty = false end end ------------------------------------------------------------------------------------- --- Unit lifecycle +-- Unit lifecycle: only track node positions ------------------------------------------------------------------------------------- function gadget:UnitCreated(unitID, unitDefID, unitTeam) if not pylonDefs[unitDefID] then return end - OnPylonAdded(spGetUnitAllyTeam(unitID), unitID, unitDefID) + local allyTeamID = spGetUnitAllyTeam(unitID) + local x, _, z = spGetUnitPosition(unitID) + nodes[allyTeamID][unitID] = { + x = x, z = z, + range = pylonDefs[unitDefID], + unitDefID = unitDefID, + } + dirty = true end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) if not pylonDefs[unitDefID] then return end - OnPylonRemoved(spGetUnitAllyTeam(unitID), unitID) + local allyTeamID = spGetUnitAllyTeam(unitID) + nodes[allyTeamID][unitID] = nil + dirty = true end function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) @@ -531,28 +448,29 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) local _, _, _, _, _, newAlly = Spring.GetTeamInfo(newTeam, false) local _, _, _, _, _, oldAlly = Spring.GetTeamInfo(oldTeam, false) if newAlly ~= oldAlly then - OnPylonRemoved(oldAlly, unitID) - OnPylonAdded(newAlly, unitID, unitDefID) + nodes[oldAlly][unitID] = nil + local x, _, z = spGetUnitPosition(unitID) + nodes[newAlly][unitID] = { + x = x, z = z, + range = pylonDefs[unitDefID], + unitDefID = unitDefID, + } + dirty = true end end function gadget:Initialize() - GG.CableTree = trees + GG.CableTree = { nodes = nodes, edges = edges } for _, unitID in ipairs(Spring.GetAllUnits()) do local unitDefID = spGetUnitDefID(unitID) if unitDefID and pylonDefs[unitDefID] then - OnPylonAdded(spGetUnitAllyTeam(unitID), unitID, unitDefID) - end - end - -- Process any queued orphan adoptions from init - if #newlyConnected > 0 then - local batch = newlyConnected - newlyConnected = {} - for i = 1, #batch do - local item = batch[i] - if item.tree.nodes[item.unitID] then - AdoptNearbyOrphans(item.tree, item.unitID) - end + local allyTeamID = spGetUnitAllyTeam(unitID) + local x, _, z = spGetUnitPosition(unitID) + nodes[allyTeamID][unitID] = { + x = x, z = z, + range = pylonDefs[unitDefID], + unitDefID = unitDefID, + } end end end @@ -563,7 +481,7 @@ end else -- UNSYNCED ------------------------------------------------------------------------------------- --- UNSYNCED — Renders cable edges onto ground texture +-- UNSYNCED — PCB-style cable rendering onto ground texture ------------------------------------------------------------------------------------- local glTexture = gl.Texture @@ -573,7 +491,6 @@ local glTexRect = gl.TexRect local glResetState = gl.ResetState local glResetMatrices = gl.ResetMatrices local glVertex = gl.Vertex -local glTexCoord = gl.TexCoord local spGetMapSquareTexture = Spring.GetMapSquareTexture local spSetMapSquareTexture = Spring.SetMapSquareTexture @@ -584,6 +501,10 @@ 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 MAP_WIDTH = Game.mapSizeX local MAP_HEIGHT = Game.mapSizeZ @@ -591,12 +512,24 @@ local SQUARE_SIZE = 1024 local SQUARES_X = MAP_WIDTH / SQUARE_SIZE local SQUARES_Z = MAP_HEIGHT / SQUARE_SIZE -local CABLE_TEXTURE = "LuaRules/Images/overdrive/cable.png" -local CABLE_TEX_HEIGHT = 64 +------------------------------------------------------------------------------------- +-- PCB style config +------------------------------------------------------------------------------------- -local MIN_CABLE_WIDTH = 6 -local MAX_CABLE_WIDTH = 28 +local MIN_TRACE_WIDTH = 5 -- min trace width in elmos +local MAX_TRACE_WIDTH = 22 -- max trace width in elmos local MAX_CAPACITY_REF = 100 +local PAD_RADIUS_MULT = 1.8 -- pad radius = trace_width * this +local PAD_SEGMENTS = 12 -- polygon segments for circular pads +local VIA_RADIUS = 3 -- small via dots along traces +local VIA_SPACING = 80 -- elmos between via dots + +-- Colors (copper on dark substrate) +local TRACE_BORDER_COLOR = { 0.02, 0.04, 0.06, 0.92 } +local TRACE_COPPER_COLOR = { 0.75, 0.55, 0.20, 0.95 } -- default copper +local PAD_BORDER_COLOR = { 0.02, 0.04, 0.06, 0.95 } +local PAD_COPPER_COLOR = { 0.85, 0.65, 0.25, 0.95 } +local VIA_COLOR = { 0.15, 0.12, 0.08, 0.90 } ------------------------------------------------------------------------------------- -- State @@ -610,215 +543,129 @@ local needsRedraw = false local drawnSquares = {} ------------------------------------------------------------------------------------- --- FBO management +-- Helpers ------------------------------------------------------------------------------------- -local function GetSquareFBO(sx, sz) - if not squareFBOs[sx] then squareFBOs[sx] = {} end - if squareFBOs[sx][sz] then return squareFBOs[sx][sz] end - local fbo = glCreateTexture(SQUARE_SIZE, SQUARE_SIZE, { - wrap_s = GL.CLAMP_TO_EDGE, wrap_t = GL.CLAMP_TO_EDGE, - fbo = true, min_filter = GL.LINEAR_MIPMAP_NEAREST, - }) - if not fbo then return nil end - squareFBOs[sx][sz] = fbo - return fbo -end - local function SquareKey(sx, sz) return sx * 10000 + sz end -------------------------------------------------------------------------------------- --- Draw textured cable onto FBO -------------------------------------------------------------------------------------- - -local function DrawCableOnSquare(sx, sz, fbo, x1, z1, x2, z2, width) - local sqX = sx * SQUARE_SIZE - local sqZ = sz * SQUARE_SIZE - local dx = x2 - x1 - local dz = z2 - z1 - local len = sqrt(dx * dx + dz * dz) - if len < 1 then return end - - local hw = width * 0.5 - local px = -dz / len * hw - local pz = dx / len * hw - - local c1x, c1z = x1 - px, z1 - pz - local c2x, c2z = x1 + px, z1 + pz - local c3x, c3z = x2 + px, z2 + pz - local c4x, c4z = x2 - px, z2 - pz - - local function w2t(wx, wz) - return 2 * (wx - sqX) / SQUARE_SIZE - 1, - 2 * (wz - sqZ) / SQUARE_SIZE - 1 - end - - local vTile = len / CABLE_TEX_HEIGHT - local t1x, t1z = w2t(c1x, c1z) - local t2x, t2z = w2t(c2x, c2z) - local t3x, t3z = w2t(c3x, c3z) - local t4x, t4z = w2t(c4x, c4z) - - gl.RenderToTexture(fbo, function() - gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) - glColor(1, 1, 1, 1) - gl.BeginEnd(GL.QUADS, function() - glTexCoord(0, 0) glVertex(t1x, t1z, 0) - glTexCoord(1, 0) glVertex(t2x, t2z, 0) - glTexCoord(1, vTile) glVertex(t3x, t3z, 0) - glTexCoord(0, vTile) glVertex(t4x, t4z, 0) - end) - end) +local function GetTraceWidth(capacity) + local t = min(1, capacity / MAX_CAPACITY_REF) + return MIN_TRACE_WIDTH + t * (MAX_TRACE_WIDTH - MIN_TRACE_WIDTH) end -------------------------------------------------------------------------------------- --- Cable width from capacity -------------------------------------------------------------------------------------- - -local function GetCableWidth(capacity) +-- Get trace color based on capacity (copper tint shifts brighter with more flow) +local function GetTraceColor(capacity) local t = min(1, capacity / MAX_CAPACITY_REF) - return MIN_CABLE_WIDTH + t * t * (MAX_CABLE_WIDTH - MIN_CABLE_WIDTH) + return 0.55 + t * 0.35, -- R: warm copper to bright gold + 0.40 + t * 0.30, -- G + 0.12 + t * 0.15, -- B + 0.95 end ------------------------------------------------------------------------------------- --- Full redraw +-- PCB routing: Manhattan + 45° chamfer +-- Given endpoints (x1,z1) → (x2,z2), produce 2-3 segments: +-- horizontal → 45° diagonal → vertical (or reverse) ------------------------------------------------------------------------------------- -local function RedrawCables() - glResetState() - glResetMatrices() +local function RoutePCB(x1, z1, x2, z2) + local dx = x2 - x1 + local dz = z2 - z1 + local adx = abs(dx) + local adz = abs(dz) - -- Revert previous squares - for _, sq in pairs(drawnSquares) do - spSetMapSquareTexture(sq.sx, sq.sz, "") + -- If nearly aligned (horizontal or vertical), just one segment + if adx < 2 or adz < 2 then + return {{ x1, z1, x2, z2 }} end - -- Build draw list with growth interpolation - local drawList = {} - for i = 1, #renderEdges do - local e = renderEdges[i] - if e.length > 0 then - local frac = min(1, e.progress / e.length) - if frac > 0.01 then - local ex = e.px + frac * (e.cx - e.px) - local ez = e.pz + frac * (e.cz - e.pz) - drawList[#drawList + 1] = { - x1 = e.px, z1 = e.pz, x2 = ex, z2 = ez, - capacity = e.capacity, - } - end - end - end + -- The shorter axis determines the 45° diagonal length + local diagLen = min(adx, adz) + local segments = {} - if #drawList == 0 then - needsRedraw = false - return - end + if adx >= adz then + -- Mostly horizontal: go horizontal first, then 45° diagonal + local horizLen = adx - diagLen + local sx = (dx > 0) and 1 or -1 + local sz = (dz > 0) and 1 or -1 - -- Determine needed squares - local neededSquares = {} - for i = 1, #drawList do - local c = drawList[i] - -- Use generous margin — 100 elmos - local m = 100 - local minSx = max(0, floor((min(c.x1, c.x2) - m) / SQUARE_SIZE)) - local maxSx = min(SQUARES_X - 1, floor((max(c.x1, c.x2) + m) / SQUARE_SIZE)) - local minSz = max(0, floor((min(c.z1, c.z2) - m) / SQUARE_SIZE)) - local maxSz = min(SQUARES_Z - 1, floor((max(c.z1, c.z2) + m) / SQUARE_SIZE)) - for sx = minSx, maxSx do - for sz = minSz, maxSz do - neededSquares[SquareKey(sx, sz)] = { sx = sx, sz = sz } - end + local mx = x1 + sx * horizLen + local mz = z1 + -- Horizontal segment + if horizLen > 2 then + segments[#segments + 1] = { x1, z1, mx, mz } end - end - - Spring.Echo("[CableTree][U] Drawing " .. #drawList .. " cables on " .. (function() local n=0; for _ in pairs(neededSquares) do n=n+1 end; return n end)() .. " squares") - - -- Snapshot current map texture into FBOs then draw cables - drawnSquares = {} - for key, sq in pairs(neededSquares) do - local fbo = GetSquareFBO(sq.sx, sq.sz) - if not fbo then - Spring.Echo("[CableTree][U] FBO creation failed for " .. sq.sx .. "," .. sq.sz) - else - -- Snapshot current texture - spGetMapSquareTexture(sq.sx, sq.sz, 0, fbo) - - -- TEST: Draw a big bright red X across the entire square to verify FBO pipeline - gl.RenderToTexture(fbo, function() - gl.Texture(false) - glColor(1, 0, 0, 1) - -- Big diagonal cross filling 80% of the square - gl.BeginEnd(GL.QUADS, function() - -- Horizontal bar across center - glVertex(-0.8, -0.05, 0) - glVertex( 0.8, -0.05, 0) - glVertex( 0.8, 0.05, 0) - glVertex(-0.8, 0.05, 0) - -- Vertical bar across center - glVertex(-0.05, -0.8, 0) - glVertex( 0.05, -0.8, 0) - glVertex( 0.05, 0.8, 0) - glVertex(-0.05, 0.8, 0) - end) - glColor(1, 1, 1, 1) - end) - - gl.GenerateMipmap(fbo) - spSetMapSquareTexture(sq.sx, sq.sz, fbo) - drawnSquares[key] = sq + -- 45° diagonal segment + segments[#segments + 1] = { mx, mz, x2, z2 } + else + -- Mostly vertical: go vertical first, then 45° diagonal + local vertLen = adz - diagLen + local sx = (dx > 0) and 1 or -1 + local sz = (dz > 0) and 1 or -1 + + local mx = x1 + local mz = z1 + sz * vertLen + -- Vertical segment + if vertLen > 2 then + segments[#segments + 1] = { x1, z1, mx, mz } end + -- 45° diagonal segment + segments[#segments + 1] = { mx, mz, x2, z2 } end - needsRedraw = false + return segments end ------------------------------------------------------------------------------------- --- Receive data from synced +-- Drawing primitives (all in FBO NDC space) ------------------------------------------------------------------------------------- -local function OnCableTreeUpdate() - local data = SYNCED.CableTreeData - if not data then return end +-- Convert world coords to FBO NDC [-1, 1] for a given square +local function MakeW2T(sqX, sqZ) + return function(wx, wz) + return 2 * (wx - sqX) / SQUARE_SIZE - 1, + 2 * (wz - sqZ) / SQUARE_SIZE - 1 + end +end - local spec, fullview = spGetSpectatingState() - local myAllyTeam = spGetMyAllyTeamID() - local allyTeamID = data.allyTeamID +-- Emit a quad for a trace segment (call inside gl.BeginEnd GL.QUADS) +local function EmitTraceQuad(w2t, x1, z1, x2, z2, hw) + local dx = x2 - x1 + local dz = z2 - z1 + local len = sqrt(dx * dx + dz * dz) + if len < 0.5 then return end - if not (spec or fullview) and allyTeamID ~= myAllyTeam then return end - if lastVersions[allyTeamID] and data.version == lastVersions[allyTeamID] then return end - lastVersions[allyTeamID] = data.version + local px = -dz / len * hw + local pz = dx / len * hw - local edges = {} - local count = data.edgeCount or 0 - for i = 1, count do - edges[i] = { - px = data.parentXs[i], pz = data.parentZs[i], - cx = data.childXs[i], cz = data.childZs[i], - progress = data.progresses[i], length = data.lengths[i], - capacity = data.capacities[i], - } - end - edgesByAllyTeam[allyTeamID] = edges + local t1x, t1z = w2t(x1 - px, z1 - pz) + local t2x, t2z = w2t(x1 + px, z1 + pz) + local t3x, t3z = w2t(x2 + px, z2 + pz) + local t4x, t4z = w2t(x2 - px, z2 - pz) - renderEdges = {} - for _, teamEdges in pairs(edgesByAllyTeam) do - for j = 1, #teamEdges do - renderEdges[#renderEdges + 1] = teamEdges[j] - end - end + glVertex(t1x, t1z, 0) + glVertex(t2x, t2z, 0) + glVertex(t3x, t3z, 0) + glVertex(t4x, t4z, 0) +end - needsRedraw = true +-- Emit a filled circle (call inside gl.BeginEnd GL.TRIANGLE_FAN) +local function EmitCircle(w2t, cx, cz, radius) + local tx, tz = w2t(cx, cz) + glVertex(tx, tz, 0) -- center + for i = 0, PAD_SEGMENTS do + local angle = (i / PAD_SEGMENTS) * PI * 2 + local px, pz = w2t(cx + cos(angle) * radius, cz + sin(angle) * radius) + glVertex(px, pz, 0) + end end ------------------------------------------------------------------------------------- -- Drawing hook ------------------------------------------------------------------------------------- -local drawCount = 0 - function gadget:DrawGenesis() if not needsRedraw then return end if #renderEdges == 0 then @@ -826,8 +673,6 @@ function gadget:DrawGenesis() return end - drawCount = drawCount + 1 - glResetState() glResetMatrices() @@ -836,37 +681,76 @@ function gadget:DrawGenesis() spSetMapSquareTexture(sq.sx, sq.sz, "") end - -- Build draw list with growth interpolation - local drawList = {} + -- Build draw list with growth interpolation + PCB routing + local allSegments = {} -- { {x1,z1,x2,z2, width, capacity}, ... } + local allPads = {} -- { {cx, cz, radius}, ... } + local padSet = {} -- dedup pads by position + for i = 1, #renderEdges do local e = renderEdges[i] if e.length > 0 then local frac = min(1, e.progress / e.length) if frac > 0.01 then - drawList[#drawList + 1] = { - x1 = e.px, z1 = e.pz, - x2 = e.px + frac * (e.cx - e.px), - z2 = e.pz + frac * (e.cz - e.pz), - capacity = e.capacity, - } + local ex = e.px + frac * (e.cx - e.px) + local ez = e.pz + frac * (e.cz - e.pz) + local w = GetTraceWidth(e.capacity) + + -- Route as PCB (Manhattan + 45°) + local segments = RoutePCB(e.px, e.pz, ex, ez) + for j = 1, #segments do + local s = segments[j] + allSegments[#allSegments + 1] = { + x1 = s[1], z1 = s[2], x2 = s[3], z2 = s[4], + width = w, capacity = e.capacity, + } + end + + -- Pad at parent position + local pkey = floor(e.px) .. "," .. floor(e.pz) + if not padSet[pkey] then + padSet[pkey] = true + allPads[#allPads + 1] = { cx = e.px, cz = e.pz, radius = w * PAD_RADIUS_MULT } + end + + -- Pad at child position (only if fully grown) + if frac >= 0.99 then + local ckey = floor(e.cx) .. "," .. floor(e.cz) + if not padSet[ckey] then + padSet[ckey] = true + allPads[#allPads + 1] = { cx = e.cx, cz = e.cz, radius = w * PAD_RADIUS_MULT } + end + end end end end - if #drawList == 0 then + if #allSegments == 0 then needsRedraw = false return end - -- Collect all squares that need updating + -- Collect needed squares (from segments + pads) local neededSquares = {} - for i = 1, #drawList do - local c = drawList[i] - local m = 100 - local minSx = max(0, floor((min(c.x1, c.x2) - m) / SQUARE_SIZE)) - local maxSx = min(SQUARES_X - 1, floor((max(c.x1, c.x2) + m) / SQUARE_SIZE)) - local minSz = max(0, floor((min(c.z1, c.z2) - m) / SQUARE_SIZE)) - local maxSz = min(SQUARES_Z - 1, floor((max(c.z1, c.z2) + m) / SQUARE_SIZE)) + for i = 1, #allSegments do + local s = allSegments[i] + local m = s.width + 20 + local minSx = max(0, floor((min(s.x1, s.x2) - m) / SQUARE_SIZE)) + local maxSx = min(SQUARES_X - 1, floor((max(s.x1, s.x2) + m) / SQUARE_SIZE)) + local minSz = max(0, floor((min(s.z1, s.z2) - m) / SQUARE_SIZE)) + local maxSz = min(SQUARES_Z - 1, floor((max(s.z1, s.z2) + m) / SQUARE_SIZE)) + for sx = minSx, maxSx do + for sz = minSz, maxSz do + neededSquares[SquareKey(sx, sz)] = { sx = sx, sz = sz } + end + end + end + for i = 1, #allPads do + local p = allPads[i] + local m = p.radius + 5 + local minSx = max(0, floor((p.cx - m) / SQUARE_SIZE)) + local maxSx = min(SQUARES_X - 1, floor((p.cx + m) / SQUARE_SIZE)) + local minSz = max(0, floor((p.cz - m) / SQUARE_SIZE)) + local maxSz = min(SQUARES_Z - 1, floor((p.cz + m) / SQUARE_SIZE)) for sx = minSx, maxSx do for sz = minSz, maxSz do neededSquares[SquareKey(sx, sz)] = { sx = sx, sz = sz } @@ -874,13 +758,12 @@ function gadget:DrawGenesis() end end - -- For each square: create orig+cur pair, snapshot, draw cables, apply - -- Using the exact pattern proven by terrain_texture_handler + -- Render on each needed square drawnSquares = {} for key, sq in pairs(neededSquares) do local sx, sz = sq.sx, sq.sz - -- Get or create the FBO pair for this square + -- Ensure FBO pair exists if not squareFBOs[sx] then squareFBOs[sx] = {} end if not squareFBOs[sx][sz] then local cur = glCreateTexture(SQUARE_SIZE, SQUARE_SIZE, { @@ -901,76 +784,78 @@ function gadget:DrawGenesis() local pair = squareFBOs[sx] and squareFBOs[sx][sz] if pair then - -- Snapshot: capture current map texture into orig + -- Snapshot orig, copy to cur spGetMapSquareTexture(sx, sz, 0, pair.orig) - - -- Copy orig → cur glTexture(pair.orig) gl.RenderToTexture(pair.cur, function() glTexRect(-1, 1, 1, -1) end) glTexture(false) - -- Draw cables onto cur: border + glowing core local sqX = sx * SQUARE_SIZE local sqZ = sz * SQUARE_SIZE - - local function w2t(wx, wz) - return 2 * (wx - sqX) / SQUARE_SIZE - 1, - 2 * (wz - sqZ) / SQUARE_SIZE - 1 - end - - local function drawQuad(x1, z1, x2, z2, hw, dx, dz, len) - local px = -dz / len * hw - local pz = dx / len * hw - local t1x, t1z = w2t(x1 - px, z1 - pz) - local t2x, t2z = w2t(x1 + px, z1 + pz) - local t3x, t3z = w2t(x2 + px, z2 + pz) - local t4x, t4z = w2t(x2 - px, z2 - pz) - glVertex(t1x, t1z, 0) - glVertex(t2x, t2z, 0) - glVertex(t3x, t3z, 0) - glVertex(t4x, t4z, 0) - end + local w2t = MakeW2T(sqX, sqZ) gl.RenderToTexture(pair.cur, function() gl.Texture(false) - -- Pass 1: dark border (full width) + -- Layer 1: Trace borders (dark substrate) + glColor(TRACE_BORDER_COLOR[1], TRACE_BORDER_COLOR[2], TRACE_BORDER_COLOR[3], TRACE_BORDER_COLOR[4]) gl.BeginEnd(GL.QUADS, function() - for i = 1, #drawList do - local c = drawList[i] - local cdx = c.x2 - c.x1 - local cdz = c.z2 - c.z1 - local clen = sqrt(cdx * cdx + cdz * cdz) - if clen > 1 then - local w = GetCableWidth(c.capacity) - glColor(0.04, 0.08, 0.12, 0.9) - drawQuad(c.x1, c.z1, c.x2, c.z2, w * 0.5, cdx, cdz, clen) - end + for i = 1, #allSegments do + local s = allSegments[i] + EmitTraceQuad(w2t, s.x1, s.z1, s.x2, s.z2, s.width * 0.55) end end) - -- Pass 2: glowing inner core (60% width, color by capacity) + -- Layer 2: Trace copper fill gl.BeginEnd(GL.QUADS, function() - for i = 1, #drawList do - local c = drawList[i] - local cdx = c.x2 - c.x1 - local cdz = c.z2 - c.z1 - local clen = sqrt(cdx * cdx + cdz * cdz) - if clen > 1 then - local w = GetCableWidth(c.capacity) - -- Color: blue for low capacity, bright cyan for high - local t = min(1, c.capacity / MAX_CAPACITY_REF) - local r = 0.05 + t * 0.15 - local g = 0.3 + t * 0.5 - local b = 0.7 + t * 0.3 - glColor(r, g, b, 0.95) - drawQuad(c.x1, c.z1, c.x2, c.z2, w * 0.3, cdx, cdz, clen) - end + for i = 1, #allSegments do + local s = allSegments[i] + local r, g, b, a = GetTraceColor(s.capacity) + glColor(r, g, b, a) + EmitTraceQuad(w2t, s.x1, s.z1, s.x2, s.z2, s.width * 0.4) end end) + -- Layer 3: Pad borders + glColor(PAD_BORDER_COLOR[1], PAD_BORDER_COLOR[2], PAD_BORDER_COLOR[3], PAD_BORDER_COLOR[4]) + for i = 1, #allPads do + local p = allPads[i] + gl.BeginEnd(GL.TRIANGLE_FAN, function() + EmitCircle(w2t, p.cx, p.cz, p.radius) + end) + end + + -- Layer 4: Pad copper fill + glColor(PAD_COPPER_COLOR[1], PAD_COPPER_COLOR[2], PAD_COPPER_COLOR[3], PAD_COPPER_COLOR[4]) + for i = 1, #allPads do + local p = allPads[i] + gl.BeginEnd(GL.TRIANGLE_FAN, function() + EmitCircle(w2t, p.cx, p.cz, p.radius * 0.75) + end) + end + + -- Layer 5: Via dots along traces + glColor(VIA_COLOR[1], VIA_COLOR[2], VIA_COLOR[3], VIA_COLOR[4]) + for i = 1, #allSegments do + local s = allSegments[i] + local dx = s.x2 - s.x1 + local dz = s.z2 - s.z1 + local len = sqrt(dx * dx + dz * dz) + if len > VIA_SPACING then + local steps = floor(len / VIA_SPACING) + for v = 1, steps do + local t = v / (steps + 1) + local vx = s.x1 + t * dx + local vz = s.z1 + t * dz + gl.BeginEnd(GL.TRIANGLE_FAN, function() + EmitCircle(w2t, vx, vz, VIA_RADIUS) + end) + end + end + end + glColor(1, 1, 1, 1) end) @@ -984,6 +869,44 @@ function gadget:DrawGenesis() needsRedraw = false end +------------------------------------------------------------------------------------- +-- Receive data from synced +------------------------------------------------------------------------------------- + +local function OnCableTreeUpdate() + local data = SYNCED.CableTreeData + if not data then return end + + local spec, fullview = spGetSpectatingState() + local myAllyTeam = spGetMyAllyTeamID() + local allyTeamID = data.allyTeamID + + if not (spec or fullview) and allyTeamID ~= myAllyTeam then return end + if lastVersions[allyTeamID] and data.version == lastVersions[allyTeamID] then return end + lastVersions[allyTeamID] = data.version + + local edges = {} + local count = data.edgeCount or 0 + for i = 1, count do + edges[i] = { + px = data.parentXs[i], pz = data.parentZs[i], + cx = data.childXs[i], cz = data.childZs[i], + progress = data.progresses[i], length = data.lengths[i], + capacity = data.capacities[i], + } + end + edgesByAllyTeam[allyTeamID] = edges + + renderEdges = {} + for _, teamEdges in pairs(edgesByAllyTeam) do + for j = 1, #teamEdges do + renderEdges[#renderEdges + 1] = teamEdges[j] + end + end + + needsRedraw = true +end + ------------------------------------------------------------------------------------- -- Lifecycle ------------------------------------------------------------------------------------- From a7caa2859bc93a01844819541af2924fcddb41b3 Mon Sep 17 00:00:00 2001 From: Licho Date: Mon, 13 Apr 2026 09:38:54 +0200 Subject: [PATCH 03/59] experiment 2, faster local changes --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 574 ++++++++++++++-------- 1 file changed, 382 insertions(+), 192 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 60bc95d23f..7ab97fe675 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -42,6 +42,7 @@ local spValidUnitID = Spring.ValidUnitID local sqrt = math.sqrt local max = math.max local min = math.min +local floor = math.floor ------------------------------------------------------------------------------------- -- Config @@ -88,13 +89,17 @@ end -- All tracked pylons per allyTeam: nodes[allyTeamID][unitID] = {x, z, range, unitDefID} local nodes = {} --- Current animated edges: edges[edgeKey] = {parentID, childID, px, pz, cx, cz, length, progress, withering} --- edgeKey = parentID .. ":" .. childID (string) +-- Edges: edges[edgeKey] = {parentID, childID, px, pz, cx, cz, length, progress, withering, gridKey} local edges = {} --- Desired edges from last grid sync: desiredEdges[edgeKey] = true +-- Desired edges: desiredEdges[edgeKey] = true local desiredEdges = {} +-- Change detection +local lastGridNum = {} -- [unitID] = gridNumber +local lastGridMembers = {} -- [gridKey] = { [unitID]=true } — who was in each grid last time +local structureChanged = true + local treeVersion = 0 local dirty = false @@ -115,13 +120,13 @@ local function Dist(x1, z1, x2, z2) return sqrt(dx * dx + dz * dz) end -local function EdgeKey(parentID, childID) - -- Canonical key: smaller ID first to avoid duplicates - if parentID < childID then - return parentID .. ":" .. childID - else - return childID .. ":" .. parentID - end +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 local function GetNodeCapacity(unitID, unitDefID) @@ -129,7 +134,6 @@ local function GetNodeCapacity(unitID, unitDefID) local stunned = spGetUnitIsStunned(unitID) or (spGetUnitRulesParam(unitID, "disarmed") == 1) if stunned then return 0, 0 end - local production, consumption = 0, 0 if generatorDefs[unitDefID] then production = spGetUnitRulesParam(unitID, "current_energyIncome") or 0 @@ -141,127 +145,225 @@ local function GetNodeCapacity(unitID, unitDefID) end ------------------------------------------------------------------------------------- --- Grid sync: read gridNumber, compute spanning trees, diff edges +-- Per-grid Prim's MST — only runs for grids whose membership changed. +-- O(k²) per changed grid where k = grid size (typically 10-50, trivial). ------------------------------------------------------------------------------------- -local function SyncWithGrid() - local newDesired = {} - - for allyTeamID, allyNodes in pairs(nodes) do - -- Group pylons by gridNumber - local pylonsByGrid = {} -- [gridID] = { {unitID, x, z, range, unitDefID}, ... } - for unitID, node in pairs(allyNodes) do - if spValidUnitID(unitID) then - local gridID = spGetUnitRulesParam(unitID, "gridNumber") or 0 - if gridID > 0 then - if not pylonsByGrid[gridID] then - pylonsByGrid[gridID] = {} - end - local list = pylonsByGrid[gridID] - list[#list + 1] = { - unitID = unitID, - x = node.x, z = node.z, - range = node.range, - unitDefID = node.unitDefID, - } - end +local SPATIAL_CELL = 600 -- spatial hash cell size (covers max pylon range pair) + +local function BuildGridMST(allyTeamID, gridID) + local pylons = {} + for unitID, node in pairs(nodes[allyTeamID]) do + if spValidUnitID(unitID) then + local gid = spGetUnitRulesParam(unitID, "gridNumber") or 0 + if gid == gridID then + pylons[#pylons + 1] = { + unitID = unitID, x = node.x, z = node.z, + range = node.range, unitDefID = node.unitDefID, + } end end + end - -- For each grid, build minimum spanning tree via Prim's algorithm. - -- Each node always connects to its nearest already-connected neighbor. - for gridID, pylons in pairs(pylonsByGrid) do - if #pylons >= 2 then - -- Pick root: highest production - local bestRoot = 1 - local bestProd = -1 - for i = 1, #pylons do - local prod = GetNodeCapacity(pylons[i].unitID, pylons[i].unitDefID) - if prod > bestProd then - bestProd = prod - bestRoot = i - end - end + local result = {} + if #pylons < 2 then return result end + + -- Build spatial hash for fast neighbor lookup + local cells = {} -- [cellKey] = { idx, idx, ... } + for i = 1, #pylons do + local p = pylons[i] + local cx = floor(p.x / SPATIAL_CELL) + local cz = floor(p.z / SPATIAL_CELL) + local ck = cx * 100000 + cz + if not cells[ck] then cells[ck] = {} end + cells[ck][#cells[ck] + 1] = i + end - local inTree = {} - inTree[bestRoot] = true - local treeSize = 1 - - while treeSize < #pylons do - local bestDistSq = math.huge - local bestJ = nil - local bestParentIdx = nil - - for j = 1, #pylons do - if not inTree[j] then - local other = pylons[j] - for k = 1, #pylons do - if inTree[k] then - local curr = pylons[k] - local dx = curr.x - other.x - local dz = curr.z - other.z - local distSq = dx * dx + dz * dz - local combinedRange = curr.range + other.range - if distSq < combinedRange * combinedRange and distSq < bestDistSq then - bestDistSq = distSq - bestJ = j - bestParentIdx = k - end - end + -- Precompute neighbor lists (indices within range) + local neighbors = {} -- [idx] = { idx, idx, ... } + for i = 1, #pylons do + neighbors[i] = {} + local p = pylons[i] + local cx = floor(p.x / SPATIAL_CELL) + local cz = floor(p.z / SPATIAL_CELL) + -- Check 3x3 neighborhood of cells + 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 ~= i then + local o = pylons[j] + local dx = p.x - o.x + local dz = p.z - o.z + local cr = p.range + o.range + if dx * dx + dz * dz < cr * cr then + neighbors[i][#neighbors[i] + 1] = j end end end + end + end + end + end - if not bestJ then break end + -- Root = highest production + local bestRoot = 1 + local bestProd = -1 + for i = 1, #pylons do + local prod = GetNodeCapacity(pylons[i].unitID, pylons[i].unitDefID) + if prod > bestProd then bestProd = prod; bestRoot = i end + end - inTree[bestJ] = true - treeSize = treeSize + 1 + -- Prim's MST using neighbor lists: O(n * avg_neighbors) + local inTree = { [bestRoot] = true } + local treeSize = 1 + -- Frontier: unvisited nodes adjacent to tree. Track best distance per node. + local bestEdge = {} -- [idx] = { distSq, treeIdx } + for _, j in ipairs(neighbors[bestRoot]) do + local p = pylons[bestRoot] + local o = pylons[j] + local dx = p.x - o.x + local dz = p.z - o.z + bestEdge[j] = { distSq = dx * dx + dz * dz, from = bestRoot } + end - local parent = pylons[bestParentIdx] - local child = pylons[bestJ] - local key = EdgeKey(parent.unitID, child.unitID) - newDesired[key] = { - parentID = parent.unitID, - childID = child.unitID, - px = parent.x, pz = parent.z, - cx = child.x, cz = child.z, - } + while treeSize < #pylons do + -- Find frontier node with smallest distance + local bestDistSq = math.huge + local bestJ = nil + for j, be in pairs(bestEdge) do + if not inTree[j] and be.distSq < bestDistSq then + bestDistSq = be.distSq + bestJ = j + end + end + + if not bestJ then break end + inTree[bestJ] = true + treeSize = treeSize + 1 + + local parentIdx = bestEdge[bestJ].from + bestEdge[bestJ] = nil + + local p, c = pylons[parentIdx], pylons[bestJ] + local key = EdgeKey(p.unitID, c.unitID) + result[key] = { + parentID = p.unitID, childID = c.unitID, + px = p.x, pz = p.z, cx = c.x, cz = c.z, + } + + -- Update frontier: check neighbors of newly added node + for _, j in ipairs(neighbors[bestJ]) do + if not inTree[j] then + local o = pylons[j] + local nj = pylons[bestJ] + local dx = nj.x - o.x + local dz = nj.z - o.z + local distSq = dx * dx + dz * dz + if not bestEdge[j] or distSq < bestEdge[j].distSq then + bestEdge[j] = { distSq = distSq, from = bestJ } end end end end - -- Diff: find new edges, removed edges, unchanged edges - -- New edges: in newDesired but not in desiredEdges → create growing edge - for key, info in pairs(newDesired) do - if not edges[key] then - local length = max(1, Dist(info.px, info.pz, info.cx, info.cz)) - edges[key] = { - parentID = info.parentID, - childID = info.childID, - px = info.px, pz = info.pz, - cx = info.cx, cz = info.cz, - length = length, - progress = 0, - withering = false, - } - dirty = true - elseif edges[key].withering then - -- Was withering, now wanted again — reverse direction - edges[key].withering = false - dirty = true + return result +end + +------------------------------------------------------------------------------------- +-- Grid sync: detect gridNumber changes, rebuild only affected grids +------------------------------------------------------------------------------------- + +-- Per-grid desired edges +local desiredByGrid = {} -- [gridKey] = { [edgeKey] = info } + +local function SyncWithGrid() + -- Detect which grids changed + local changedGrids = {} -- [gridKey] = { allyTeamID, gridID } + + for allyTeamID, allyNodes in pairs(nodes) do + -- Clean up dead units first + local toRemove = {} + for unitID, _ in pairs(allyNodes) do + if not spValidUnitID(unitID) then + toRemove[#toRemove + 1] = unitID + end + end + for i = 1, #toRemove do + local uid = toRemove[i] + local oldGrid = lastGridNum[uid] or 0 + if oldGrid > 0 then + changedGrids[GridKey(allyTeamID, oldGrid)] = { allyTeamID = allyTeamID, gridID = oldGrid } + end + allyNodes[uid] = nil + lastGridNum[uid] = nil + end + + -- Check living units for grid changes + for unitID, _ in pairs(allyNodes) do + local gridID = spGetUnitRulesParam(unitID, "gridNumber") or 0 + local oldGrid = lastGridNum[unitID] or 0 + if gridID ~= oldGrid then + lastGridNum[unitID] = gridID + if oldGrid > 0 then + changedGrids[GridKey(allyTeamID, oldGrid)] = { allyTeamID = allyTeamID, gridID = oldGrid } + end + if gridID > 0 then + changedGrids[GridKey(allyTeamID, gridID)] = { allyTeamID = allyTeamID, gridID = gridID } + end + end end end - -- Removed edges: in desiredEdges but not in newDesired → start withering - for key, _ in pairs(desiredEdges) do - if not newDesired[key] and edges[key] and not edges[key].withering then - edges[key].withering = true - dirty = true + -- Nothing changed? + local hasChanges = false + for _ in pairs(changedGrids) do hasChanges = true; break end + if not hasChanges then + structureChanged = false + return + end + + -- Rebuild only changed grids + for gk, info in pairs(changedGrids) do + -- Wither old edges for this grid + if desiredByGrid[gk] then + for ek, _ in pairs(desiredByGrid[gk]) do + if edges[ek] and not edges[ek].withering then + edges[ek].withering = true + desiredEdges[ek] = nil + dirty = true + end + end + end + + -- Compute new MST + local newEdges = BuildGridMST(info.allyTeamID, info.gridID) + desiredByGrid[gk] = newEdges + + -- Create or revive edges + for ek, einfo in pairs(newEdges) do + desiredEdges[ek] = true + if edges[ek] then + if edges[ek].withering then + edges[ek].withering = false + dirty = true + end + else + edges[ek] = { + parentID = einfo.parentID, childID = einfo.childID, + px = einfo.px, pz = einfo.pz, cx = einfo.cx, cz = einfo.cz, + length = max(1, Dist(einfo.px, einfo.pz, einfo.cx, einfo.cz)), + progress = 0, withering = false, + } + dirty = true + end end end - desiredEdges = newDesired + structureChanged = false end ------------------------------------------------------------------------------------- @@ -304,17 +406,13 @@ local function ComputeFlows() local nodeSet = {} for key, edge in pairs(edges) do if edge.progress >= edge.length and not edge.withering then - -- Fully grown edge — use BFS parent→child direction from desired edges - local info = desiredEdges[key] - if info then - local pid, cid = info.parentID, info.childID - if not children[pid] then children[pid] = {} end - children[pid][#children[pid] + 1] = cid - parentOf[cid] = pid - edgeByChild[cid] = key - nodeSet[pid] = true - nodeSet[cid] = true - end + local pid, cid = edge.parentID, edge.childID + if not children[pid] then children[pid] = {} end + children[pid][#children[pid] + 1] = cid + parentOf[cid] = pid + edgeByChild[cid] = key + nodeSet[pid] = true + nodeSet[cid] = true end end @@ -366,35 +464,53 @@ end local function SendToUnsyncedAll() local flows = ComputeFlows() - local edgeCount = 0 - local parentXs, parentZs = {}, {} - local childXs, childZs = {}, {} - local progresses, lengths, capacities = {}, {}, {} - local allyTeamID = 0 -- we send all edges in one batch + -- Build per-allyTeam edge lists (so unsynced only sees own team's cables) + local perAlly = {} -- [allyTeamID] = { edgeCount, parentXs, ... } + -- Figure out which allyTeam each edge belongs to by checking parentID for key, edge in pairs(edges) do if edge.progress > 0 then - edgeCount = edgeCount + 1 - parentXs[edgeCount] = edge.px - parentZs[edgeCount] = edge.pz - childXs[edgeCount] = edge.cx - childZs[edgeCount] = edge.cz - progresses[edgeCount] = edge.progress - lengths[edgeCount] = edge.length - capacities[edgeCount] = flows[key] or 0 + -- Find allyTeam of this edge's parent + local atID + for allyTeamID, allyNodes in pairs(nodes) do + if allyNodes[edge.parentID] or allyNodes[edge.childID] then + atID = allyTeamID + break + end + end + if atID then + if not perAlly[atID] then + perAlly[atID] = { + edgeCount = 0, + parentXs = {}, parentZs = {}, + childXs = {}, childZs = {}, + progresses = {}, lengths = {}, capacities = {}, + } + end + local pa = perAlly[atID] + pa.edgeCount = pa.edgeCount + 1 + local n = pa.edgeCount + pa.parentXs[n] = edge.px + pa.parentZs[n] = edge.pz + pa.childXs[n] = edge.cx + pa.childZs[n] = edge.cz + pa.progresses[n] = edge.progress + pa.lengths[n] = edge.length + pa.capacities[n] = flows[key] or 0 + end end end - -- Send for each allyTeam that has nodes (spectators see all) - for atID, _ in pairs(nodes) do + -- Send one message per allyTeam + for atID, pa in pairs(perAlly) do _G.CableTreeData = { allyTeamID = atID, version = treeVersion, - edgeCount = edgeCount, - parentXs = parentXs, parentZs = parentZs, - childXs = childXs, childZs = childZs, - progresses = progresses, lengths = lengths, - capacities = capacities, + edgeCount = pa.edgeCount, + parentXs = pa.parentXs, parentZs = pa.parentZs, + childXs = pa.childXs, childZs = pa.childZs, + progresses = pa.progresses, lengths = pa.lengths, + capacities = pa.capacities, } SendToUnsynced("CableTreeUpdate") end @@ -405,6 +521,8 @@ end ------------------------------------------------------------------------------------- function gadget:GameFrame(n) + -- Check for gridNumber changes even without unit create/destroy + -- (stun, disable, activate can change grid membership) if n % SYNC_PERIOD == 2 then SyncWithGrid() end @@ -433,29 +551,35 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam) range = pylonDefs[unitDefID], unitDefID = unitDefID, } - dirty = true + structureChanged = true end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) if not pylonDefs[unitDefID] then return end - local allyTeamID = spGetUnitAllyTeam(unitID) - nodes[allyTeamID][unitID] = nil - dirty = true + -- Don't remove from nodes/lastGridNum here. + -- SyncWithGrid will detect the dead unit via spValidUnitID, + -- mark the affected grid as changed, and clean up. + structureChanged = true 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 - nodes[oldAlly][unitID] = nil - local x, _, z = spGetUnitPosition(unitID) - nodes[newAlly][unitID] = { - x = x, z = z, - range = pylonDefs[unitDefID], - unitDefID = unitDefID, - } - dirty = true + -- Remove from old allyTeam, add to new + if nodes[oldAlly] then nodes[oldAlly][unitID] = nil end + lastGridNum[unitID] = nil + if nodes[newAlly] then + local x, _, z = spGetUnitPosition(unitID) + nodes[newAlly][unitID] = { + x = x, z = z, + range = pylonDefs[unitDefID], + unitDefID = unitDefID, + } + end + structureChanged = true end end @@ -566,8 +690,7 @@ end ------------------------------------------------------------------------------------- -- PCB routing: Manhattan + 45° chamfer --- Given endpoints (x1,z1) → (x2,z2), produce 2-3 segments: --- horizontal → 45° diagonal → vertical (or reverse) +-- Returns list of segments { {x1, z1, x2, z2}, ... } ------------------------------------------------------------------------------------- local function RoutePCB(x1, z1, x2, z2) @@ -576,48 +699,106 @@ local function RoutePCB(x1, z1, x2, z2) local adx = abs(dx) local adz = abs(dz) - -- If nearly aligned (horizontal or vertical), just one segment if adx < 2 or adz < 2 then return {{ x1, z1, x2, z2 }} end - -- The shorter axis determines the 45° diagonal length local diagLen = min(adx, adz) local segments = {} if adx >= adz then - -- Mostly horizontal: go horizontal first, then 45° diagonal local horizLen = adx - diagLen local sx = (dx > 0) and 1 or -1 - local sz = (dz > 0) and 1 or -1 - local mx = x1 + sx * horizLen - local mz = z1 - -- Horizontal segment if horizLen > 2 then - segments[#segments + 1] = { x1, z1, mx, mz } + segments[#segments + 1] = { x1, z1, mx, z1 } end - -- 45° diagonal segment - segments[#segments + 1] = { mx, mz, x2, z2 } + segments[#segments + 1] = { mx, z1, x2, z2 } else - -- Mostly vertical: go vertical first, then 45° diagonal local vertLen = adz - diagLen - local sx = (dx > 0) and 1 or -1 local sz = (dz > 0) and 1 or -1 - - local mx = x1 local mz = z1 + sz * vertLen - -- Vertical segment if vertLen > 2 then - segments[#segments + 1] = { x1, z1, mx, mz } + segments[#segments + 1] = { x1, z1, x1, mz } end - -- 45° diagonal segment - segments[#segments + 1] = { mx, mz, x2, z2 } + segments[#segments + 1] = { x1, mz, x2, z2 } end return segments end +------------------------------------------------------------------------------------- +-- Segment merging: when multiple routed edges produce collinear overlapping +-- segments, merge them into a single segment with accumulated capacity. +-- Only merges axis-aligned (horizontal/vertical) segments. +------------------------------------------------------------------------------------- + +local SNAP = 16 -- snap resolution for merging (elmos) + +local function SegKey(isHoriz, fixed, lo, hi) + -- Key for an axis-aligned segment: axis + snapped fixed coord + interval + return (isHoriz and "H" or "V") .. floor(fixed / SNAP) .. ":" .. floor(lo / SNAP) .. ":" .. floor(hi / SNAP) +end + +local function MergeSegments(rawSegments) + -- rawSegments = { {x1,z1,x2,z2,capacity}, ... } + -- Returns merged list: { {x1,z1,x2,z2,capacity}, ... } + + local axisSegs = {} -- [segKey] = { fixed, lo, hi, capacity, isHoriz } + local diagSegs = {} -- diagonal segments can't be merged, pass through + + for i = 1, #rawSegments do + local s = rawSegments[i] + local dx = abs(s[3] - s[1]) + local dz = abs(s[4] - s[2]) + + if dz < 2 then + -- Horizontal segment + local fixed = s[2] + local lo = min(s[1], s[3]) + local hi = max(s[1], s[3]) + local key = SegKey(true, fixed, lo, hi) + if axisSegs[key] then + axisSegs[key].capacity = axisSegs[key].capacity + s[5] + else + axisSegs[key] = { fixed = fixed, lo = lo, hi = hi, capacity = s[5], isHoriz = true } + end + elseif dx < 2 then + -- Vertical segment + local fixed = s[1] + local lo = min(s[2], s[4]) + local hi = max(s[2], s[4]) + local key = SegKey(false, fixed, lo, hi) + if axisSegs[key] then + axisSegs[key].capacity = axisSegs[key].capacity + s[5] + else + axisSegs[key] = { fixed = fixed, lo = lo, hi = hi, capacity = s[5], isHoriz = false } + end + else + -- Diagonal — just pass through + diagSegs[#diagSegs + 1] = { s[1], s[2], s[3], s[4], capacity = s[5] } + end + end + + -- Convert back to segment list with named fields + local result = {} + for _, seg in pairs(axisSegs) do + local w = GetTraceWidth(seg.capacity) + if seg.isHoriz then + result[#result + 1] = { x1 = seg.lo, z1 = seg.fixed, x2 = seg.hi, z2 = seg.fixed, width = w, capacity = seg.capacity } + else + result[#result + 1] = { x1 = seg.fixed, z1 = seg.lo, x2 = seg.fixed, z2 = seg.hi, width = w, capacity = seg.capacity } + end + end + for i = 1, #diagSegs do + local d = diagSegs[i] + local w = GetTraceWidth(d.capacity) + result[#result + 1] = { x1 = d[1], z1 = d[2], x2 = d[3], z2 = d[4], width = w, capacity = d.capacity } + end + + return result +end + ------------------------------------------------------------------------------------- -- Drawing primitives (all in FBO NDC space) ------------------------------------------------------------------------------------- @@ -681,10 +862,11 @@ function gadget:DrawGenesis() spSetMapSquareTexture(sq.sx, sq.sz, "") end - -- Build draw list with growth interpolation + PCB routing - local allSegments = {} -- { {x1,z1,x2,z2, width, capacity}, ... } - local allPads = {} -- { {cx, cz, radius}, ... } - local padSet = {} -- dedup pads by position + -- Step 1: Route all edges as PCB segments, collect raw segments with capacity + local rawSegments = {} + local allPads = {} + local padSet = {} + local maxPadRadius = {} -- [padKey] = max radius seen for i = 1, #renderEdges do local e = renderEdges[i] @@ -693,42 +875,46 @@ function gadget:DrawGenesis() if frac > 0.01 then local ex = e.px + frac * (e.cx - e.px) local ez = e.pz + frac * (e.cz - e.pz) - local w = GetTraceWidth(e.capacity) + local cap = max(1, e.capacity) - -- Route as PCB (Manhattan + 45°) local segments = RoutePCB(e.px, e.pz, ex, ez) for j = 1, #segments do local s = segments[j] - allSegments[#allSegments + 1] = { - x1 = s[1], z1 = s[2], x2 = s[3], z2 = s[4], - width = w, capacity = e.capacity, - } + rawSegments[#rawSegments + 1] = { s[1], s[2], s[3], s[4], cap } end - -- Pad at parent position + -- Pads at endpoints (accumulate max radius) + local w = GetTraceWidth(cap) local pkey = floor(e.px) .. "," .. floor(e.pz) - if not padSet[pkey] then - padSet[pkey] = true - allPads[#allPads + 1] = { cx = e.px, cz = e.pz, radius = w * PAD_RADIUS_MULT } + if not maxPadRadius[pkey] or w * PAD_RADIUS_MULT > maxPadRadius[pkey] then + maxPadRadius[pkey] = w * PAD_RADIUS_MULT + padSet[pkey] = { cx = e.px, cz = e.pz } end - -- Pad at child position (only if fully grown) if frac >= 0.99 then local ckey = floor(e.cx) .. "," .. floor(e.cz) - if not padSet[ckey] then - padSet[ckey] = true - allPads[#allPads + 1] = { cx = e.cx, cz = e.cz, radius = w * PAD_RADIUS_MULT } + if not maxPadRadius[ckey] or w * PAD_RADIUS_MULT > maxPadRadius[ckey] then + maxPadRadius[ckey] = w * PAD_RADIUS_MULT + padSet[ckey] = { cx = e.cx, cz = e.cz } end end end end end - if #allSegments == 0 then + if #rawSegments == 0 then needsRedraw = false return end + -- Step 2: Merge overlapping axis-aligned segments (reinforcement) + local allSegments = MergeSegments(rawSegments) + + -- Build pad list with accumulated radii + for pkey, pdata in pairs(padSet) do + allPads[#allPads + 1] = { cx = pdata.cx, cz = pdata.cz, radius = maxPadRadius[pkey] } + end + -- Collect needed squares (from segments + pads) local neededSquares = {} for i = 1, #allSegments do @@ -808,7 +994,8 @@ function gadget:DrawGenesis() end end) - -- Layer 2: Trace copper fill + -- Layer 2: Trace copper fill (additive so overlapping traces reinforce) + gl.Blending(GL.SRC_ALPHA, GL.ONE) gl.BeginEnd(GL.QUADS, function() for i = 1, #allSegments do local s = allSegments[i] @@ -818,6 +1005,9 @@ function gadget:DrawGenesis() end end) + -- Reset blending for pads + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + -- Layer 3: Pad borders glColor(PAD_BORDER_COLOR[1], PAD_BORDER_COLOR[2], PAD_BORDER_COLOR[3], PAD_BORDER_COLOR[4]) for i = 1, #allPads do From a029e2315e2090741c94cd3ce943fe3b07e0eed0 Mon Sep 17 00:00:00 2001 From: Licho Date: Mon, 13 Apr 2026 09:52:33 +0200 Subject: [PATCH 04/59] tree --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 338 +++++++++++----------- 1 file changed, 175 insertions(+), 163 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 7ab97fe675..cbc87f7d4c 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -637,23 +637,35 @@ local SQUARES_X = MAP_WIDTH / SQUARE_SIZE local SQUARES_Z = MAP_HEIGHT / SQUARE_SIZE ------------------------------------------------------------------------------------- --- PCB style config +-- Organic tree style config ------------------------------------------------------------------------------------- -local MIN_TRACE_WIDTH = 5 -- min trace width in elmos -local MAX_TRACE_WIDTH = 22 -- max trace width in elmos +local MIN_TRUNK_WIDTH = 4 +local MAX_TRUNK_WIDTH = 20 local MAX_CAPACITY_REF = 100 -local PAD_RADIUS_MULT = 1.8 -- pad radius = trace_width * this -local PAD_SEGMENTS = 12 -- polygon segments for circular pads -local VIA_RADIUS = 3 -- small via dots along traces -local VIA_SPACING = 80 -- elmos between via dots - --- Colors (copper on dark substrate) -local TRACE_BORDER_COLOR = { 0.02, 0.04, 0.06, 0.92 } -local TRACE_COPPER_COLOR = { 0.75, 0.55, 0.20, 0.95 } -- default copper -local PAD_BORDER_COLOR = { 0.02, 0.04, 0.06, 0.95 } -local PAD_COPPER_COLOR = { 0.85, 0.65, 0.25, 0.95 } -local VIA_COLOR = { 0.15, 0.12, 0.08, 0.90 } +local PAD_SEGMENTS = 10 + +-- Noise & branching +local SEG_LENGTH = 16 -- subdivide cables every N elmos +local NOISE_AMP = 0.6 -- noise amplitude as fraction of width +local BRANCH_CHANCE = 0.25 -- chance of side branch per segment +local BRANCH_LEN_MIN = 15 -- min branch length (elmos) +local BRANCH_LEN_MAX = 50 -- max branch length +local BRANCH_ANGLE_MIN = 0.4 -- min angle offset (radians, ~23°) +local BRANCH_ANGLE_MAX = 1.1 -- max angle offset (radians, ~63°) +local BRANCH_WIDTH = 0.5 -- branch width as fraction of parent +local TWIG_CHANCE = 0.3 -- chance of sub-branch from a branch +local TWIG_LEN_MIN = 8 +local TWIG_LEN_MAX = 25 +local TWIG_WIDTH = 0.4 +local TAPER_START = 0.7 -- start tapering at this fraction along cable + +-- Colors (organic: dark bark border, greenish/amber glow) +local BARK_COLOR = { 0.06, 0.04, 0.02, 0.90 } +local INNER_COLOR = { 0.20, 0.55, 0.15, 0.85 } -- green energy +local INNER_COLOR_HIGH = { 0.50, 0.80, 0.20, 0.90 } -- bright for high capacity +local PAD_BARK_COLOR = { 0.08, 0.05, 0.02, 0.92 } +local PAD_INNER_COLOR = { 0.25, 0.60, 0.18, 0.90 } ------------------------------------------------------------------------------------- -- State @@ -693,110 +705,139 @@ end -- Returns list of segments { {x1, z1, x2, z2}, ... } ------------------------------------------------------------------------------------- -local function RoutePCB(x1, z1, x2, z2) - local dx = x2 - x1 - local dz = z2 - z1 - local adx = abs(dx) - local adz = abs(dz) - - if adx < 2 or adz < 2 then - return {{ x1, z1, x2, z2 }} - end - - local diagLen = min(adx, adz) - local segments = {} +------------------------------------------------------------------------------------- +-- Deterministic noise: hash-based pseudo-random from position +-- Returns value in [-1, 1], stable for same inputs across redraws +------------------------------------------------------------------------------------- - if adx >= adz then - local horizLen = adx - diagLen - local sx = (dx > 0) and 1 or -1 - local mx = x1 + sx * horizLen - if horizLen > 2 then - segments[#segments + 1] = { x1, z1, mx, z1 } - end - segments[#segments + 1] = { mx, z1, x2, z2 } - else - local vertLen = adz - diagLen - local sz = (dz > 0) and 1 or -1 - local mz = z1 + sz * vertLen - if vertLen > 2 then - segments[#segments + 1] = { x1, z1, x1, mz } - end - segments[#segments + 1] = { x1, mz, x2, z2 } - end +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 -- [-1, 1] +end - return segments +local function HashUnit(x, z, seed) -- [0, 1] + return (Hash(x, z, seed) + 1) * 0.5 end ------------------------------------------------------------------------------------- --- Segment merging: when multiple routed edges produce collinear overlapping --- segments, merge them into a single segment with accumulated capacity. --- Only merges axis-aligned (horizontal/vertical) segments. +-- Organic tree path generator +-- Takes a cable edge and produces many small noisy segments + side branches. ------------------------------------------------------------------------------------- -local SNAP = 16 -- snap resolution for merging (elmos) +local function GetTrunkWidth(capacity) + local t = min(1, capacity / MAX_CAPACITY_REF) + return MIN_TRUNK_WIDTH + t * (MAX_TRUNK_WIDTH - MIN_TRUNK_WIDTH) +end + +-- Generate noisy path points along a line from (x1,z1) to (x2,z2) +-- Returns array of {x, z} waypoints with perpendicular noise +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 -- perpendicular + local nz = dx / len + + local points = {} + for i = 0, steps do + local t = i / steps + local px = x1 + t * dx + local pz = z1 + t * dz -local function SegKey(isHoriz, fixed, lo, hi) - -- Key for an axis-aligned segment: axis + snapped fixed coord + interval - return (isHoriz and "H" or "V") .. floor(fixed / SNAP) .. ":" .. floor(lo / SNAP) .. ":" .. floor(hi / SNAP) + -- No noise at endpoints (connect cleanly to pads) + 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) * amplitude * noiseScale + points[#points + 1] = { x = px + nx * n, z = pz + nz * n } + end + return points end -local function MergeSegments(rawSegments) - -- rawSegments = { {x1,z1,x2,z2,capacity}, ... } - -- Returns merged list: { {x1,z1,x2,z2,capacity}, ... } - - local axisSegs = {} -- [segKey] = { fixed, lo, hi, capacity, isHoriz } - local diagSegs = {} -- diagonal segments can't be merged, pass through - - for i = 1, #rawSegments do - local s = rawSegments[i] - local dx = abs(s[3] - s[1]) - local dz = abs(s[4] - s[2]) - - if dz < 2 then - -- Horizontal segment - local fixed = s[2] - local lo = min(s[1], s[3]) - local hi = max(s[1], s[3]) - local key = SegKey(true, fixed, lo, hi) - if axisSegs[key] then - axisSegs[key].capacity = axisSegs[key].capacity + s[5] - else - axisSegs[key] = { fixed = fixed, lo = lo, hi = hi, capacity = s[5], isHoriz = true } - end - elseif dx < 2 then - -- Vertical segment - local fixed = s[1] - local lo = min(s[2], s[4]) - local hi = max(s[2], s[4]) - local key = SegKey(false, fixed, lo, hi) - if axisSegs[key] then - axisSegs[key].capacity = axisSegs[key].capacity + s[5] - else - axisSegs[key] = { fixed = fixed, lo = lo, hi = hi, capacity = s[5], isHoriz = false } - end - else - -- Diagonal — just pass through - diagSegs[#diagSegs + 1] = { s[1], s[2], s[3], s[4], capacity = s[5] } +-- Generate organic segments for one cable edge. +-- Returns list of { x1, z1, x2, z2, width, capacity, isBranch } +local function GenerateOrganicEdge(ex1, ez1, ex2, ez2, capacity) + local segments = {} + local trunkW = GetTrunkWidth(capacity) + local len = sqrt((ex2 - ex1)^2 + (ez2 - ez1)^2) + if len < 2 then return segments end + + local dx = (ex2 - ex1) / len + local dz = (ez2 - ez1) / len + + -- Generate noisy trunk path + local path = NoisyPath(ex1, ez1, ex2, ez2, trunkW * NOISE_AMP, ex1 + ez1) + + -- Emit trunk segments with taper + for i = 1, #path - 1 do + local p1 = path[i] + local p2 = path[i + 1] + local t = (i - 1) / (#path - 1) -- 0..1 along cable + + -- Taper: full width until TAPER_START, then narrow to 60% + local w = trunkW + if t > TAPER_START then + local taperT = (t - TAPER_START) / (1 - TAPER_START) + w = trunkW * (1 - taperT * 0.4) end - end - -- Convert back to segment list with named fields - local result = {} - for _, seg in pairs(axisSegs) do - local w = GetTraceWidth(seg.capacity) - if seg.isHoriz then - result[#result + 1] = { x1 = seg.lo, z1 = seg.fixed, x2 = seg.hi, z2 = seg.fixed, width = w, capacity = seg.capacity } - else - result[#result + 1] = { x1 = seg.fixed, z1 = seg.lo, x2 = seg.fixed, z2 = seg.hi, width = w, capacity = seg.capacity } + segments[#segments + 1] = { + x1 = p1.x, z1 = p1.z, x2 = p2.x, z2 = p2.z, + width = w, capacity = capacity, isBranch = false, + } + + -- Side branches + if i > 1 and i < #path - 1 then + local branchSeed = p1.x * 7.13 + p1.z * 3.77 + if HashUnit(p1.x, p1.z, branchSeed) < BRANCH_CHANCE then + -- Branch direction: perpendicular with random angle offset + local side = (Hash(p1.x, p1.z, branchSeed + 1) > 0) and 1 or -1 + local angle = BRANCH_ANGLE_MIN + HashUnit(p1.x, p1.z, branchSeed + 2) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN) + local bAngle = math.atan2(dz, dx) + side * angle + local bLen = BRANCH_LEN_MIN + HashUnit(p1.x, p1.z, branchSeed + 3) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN) + local bx2 = p1.x + cos(bAngle) * bLen + local bz2 = p1.z + sin(bAngle) * bLen + local bw = w * BRANCH_WIDTH + + -- Noisy branch path (fewer segments) + local bPath = NoisyPath(p1.x, p1.z, bx2, bz2, bw * 0.8, branchSeed + 10) + for bi = 1, #bPath - 1 do + local bp1 = bPath[bi] + local bp2 = bPath[bi + 1] + local bt = bi / (#bPath - 1) + local bwTaper = bw * (1 - bt * 0.6) -- taper to 40% at tip + + segments[#segments + 1] = { + x1 = bp1.x, z1 = bp1.z, x2 = bp2.x, z2 = bp2.z, + width = bwTaper, capacity = capacity * 0.3, isBranch = true, + } + + -- Sub-twigs + if bi > 1 and bi < #bPath - 1 and HashUnit(bp1.x, bp1.z, branchSeed + 20 + bi) < TWIG_CHANCE then + local tSide = (Hash(bp1.x, bp1.z, branchSeed + 30 + bi) > 0) and 1 or -1 + local tAngle = bAngle + tSide * (0.3 + HashUnit(bp1.x, bp1.z, branchSeed + 40 + bi) * 0.8) + local tLen = TWIG_LEN_MIN + HashUnit(bp1.x, bp1.z, branchSeed + 50 + bi) * (TWIG_LEN_MAX - TWIG_LEN_MIN) + local tx2 = bp1.x + cos(tAngle) * tLen + local tz2 = bp1.z + sin(tAngle) * tLen + local tw = bwTaper * TWIG_WIDTH + + segments[#segments + 1] = { + x1 = bp1.x, z1 = bp1.z, x2 = tx2, z2 = tz2, + width = tw, capacity = capacity * 0.1, isBranch = true, + } + end + end + end end end - for i = 1, #diagSegs do - local d = diagSegs[i] - local w = GetTraceWidth(d.capacity) - result[#result + 1] = { x1 = d[1], z1 = d[2], x2 = d[3], z2 = d[4], width = w, capacity = d.capacity } - end - return result + return segments end ------------------------------------------------------------------------------------- @@ -862,11 +903,10 @@ function gadget:DrawGenesis() spSetMapSquareTexture(sq.sx, sq.sz, "") end - -- Step 1: Route all edges as PCB segments, collect raw segments with capacity - local rawSegments = {} + -- Generate organic tree segments from all edges + local allSegments = {} local allPads = {} local padSet = {} - local maxPadRadius = {} -- [padKey] = max radius seen for i = 1, #renderEdges do local e = renderEdges[i] @@ -877,49 +917,40 @@ function gadget:DrawGenesis() local ez = e.pz + frac * (e.cz - e.pz) local cap = max(1, e.capacity) - local segments = RoutePCB(e.px, e.pz, ex, ez) - for j = 1, #segments do - local s = segments[j] - rawSegments[#rawSegments + 1] = { s[1], s[2], s[3], s[4], cap } + -- Generate organic noisy path with branches + local segs = GenerateOrganicEdge(e.px, e.pz, ex, ez, cap) + for j = 1, #segs do + allSegments[#allSegments + 1] = segs[j] end - -- Pads at endpoints (accumulate max radius) - local w = GetTraceWidth(cap) + -- Pads at endpoints + local w = GetTrunkWidth(cap) local pkey = floor(e.px) .. "," .. floor(e.pz) - if not maxPadRadius[pkey] or w * PAD_RADIUS_MULT > maxPadRadius[pkey] then - maxPadRadius[pkey] = w * PAD_RADIUS_MULT - padSet[pkey] = { cx = e.px, cz = e.pz } + if not padSet[pkey] then + padSet[pkey] = true + allPads[#allPads + 1] = { cx = e.px, cz = e.pz, radius = w * 1.5 } end - if frac >= 0.99 then local ckey = floor(e.cx) .. "," .. floor(e.cz) - if not maxPadRadius[ckey] or w * PAD_RADIUS_MULT > maxPadRadius[ckey] then - maxPadRadius[ckey] = w * PAD_RADIUS_MULT - padSet[ckey] = { cx = e.cx, cz = e.cz } + if not padSet[ckey] then + padSet[ckey] = true + allPads[#allPads + 1] = { cx = e.cx, cz = e.cz, radius = w * 1.2 } end end end end end - if #rawSegments == 0 then + if #allSegments == 0 then needsRedraw = false return end - -- Step 2: Merge overlapping axis-aligned segments (reinforcement) - local allSegments = MergeSegments(rawSegments) - - -- Build pad list with accumulated radii - for pkey, pdata in pairs(padSet) do - allPads[#allPads + 1] = { cx = pdata.cx, cz = pdata.cz, radius = maxPadRadius[pkey] } - end - -- Collect needed squares (from segments + pads) local neededSquares = {} for i = 1, #allSegments do local s = allSegments[i] - local m = s.width + 20 + local m = s.width + 60 -- extra margin for branch noise local minSx = max(0, floor((min(s.x1, s.x2) - m) / SQUARE_SIZE)) local maxSx = min(SQUARES_X - 1, floor((max(s.x1, s.x2) + m) / SQUARE_SIZE)) local minSz = max(0, floor((min(s.z1, s.z2) - m) / SQUARE_SIZE)) @@ -985,8 +1016,8 @@ function gadget:DrawGenesis() gl.RenderToTexture(pair.cur, function() gl.Texture(false) - -- Layer 1: Trace borders (dark substrate) - glColor(TRACE_BORDER_COLOR[1], TRACE_BORDER_COLOR[2], TRACE_BORDER_COLOR[3], TRACE_BORDER_COLOR[4]) + -- Layer 1: Dark bark border (all segments) + glColor(BARK_COLOR[1], BARK_COLOR[2], BARK_COLOR[3], BARK_COLOR[4]) gl.BeginEnd(GL.QUADS, function() for i = 1, #allSegments do local s = allSegments[i] @@ -994,22 +1025,23 @@ function gadget:DrawGenesis() end end) - -- Layer 2: Trace copper fill (additive so overlapping traces reinforce) - gl.Blending(GL.SRC_ALPHA, GL.ONE) + -- Layer 2: Inner glow (energy color, varies by capacity) gl.BeginEnd(GL.QUADS, function() for i = 1, #allSegments do local s = allSegments[i] - local r, g, b, a = GetTraceColor(s.capacity) + local t = min(1, s.capacity / MAX_CAPACITY_REF) + local r = INNER_COLOR[1] + t * (INNER_COLOR_HIGH[1] - INNER_COLOR[1]) + local g = INNER_COLOR[2] + t * (INNER_COLOR_HIGH[2] - INNER_COLOR[2]) + local b = INNER_COLOR[3] + t * (INNER_COLOR_HIGH[3] - INNER_COLOR[3]) + local a = INNER_COLOR[4] + if s.isBranch then a = a * 0.7 end glColor(r, g, b, a) - EmitTraceQuad(w2t, s.x1, s.z1, s.x2, s.z2, s.width * 0.4) + EmitTraceQuad(w2t, s.x1, s.z1, s.x2, s.z2, s.width * 0.35) end end) - -- Reset blending for pads - gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) - - -- Layer 3: Pad borders - glColor(PAD_BORDER_COLOR[1], PAD_BORDER_COLOR[2], PAD_BORDER_COLOR[3], PAD_BORDER_COLOR[4]) + -- Layer 3: Pad borders (dark bark circles at nodes) + glColor(PAD_BARK_COLOR[1], PAD_BARK_COLOR[2], PAD_BARK_COLOR[3], PAD_BARK_COLOR[4]) for i = 1, #allPads do local p = allPads[i] gl.BeginEnd(GL.TRIANGLE_FAN, function() @@ -1017,35 +1049,15 @@ function gadget:DrawGenesis() end) end - -- Layer 4: Pad copper fill - glColor(PAD_COPPER_COLOR[1], PAD_COPPER_COLOR[2], PAD_COPPER_COLOR[3], PAD_COPPER_COLOR[4]) + -- Layer 4: Pad inner glow + glColor(PAD_INNER_COLOR[1], PAD_INNER_COLOR[2], PAD_INNER_COLOR[3], PAD_INNER_COLOR[4]) for i = 1, #allPads do local p = allPads[i] gl.BeginEnd(GL.TRIANGLE_FAN, function() - EmitCircle(w2t, p.cx, p.cz, p.radius * 0.75) + EmitCircle(w2t, p.cx, p.cz, p.radius * 0.65) end) end - -- Layer 5: Via dots along traces - glColor(VIA_COLOR[1], VIA_COLOR[2], VIA_COLOR[3], VIA_COLOR[4]) - for i = 1, #allSegments do - local s = allSegments[i] - local dx = s.x2 - s.x1 - local dz = s.z2 - s.z1 - local len = sqrt(dx * dx + dz * dz) - if len > VIA_SPACING then - local steps = floor(len / VIA_SPACING) - for v = 1, steps do - local t = v / (steps + 1) - local vx = s.x1 + t * dx - local vz = s.z1 + t * dz - gl.BeginEnd(GL.TRIANGLE_FAN, function() - EmitCircle(w2t, vx, vz, VIA_RADIUS) - end) - end - end - end - glColor(1, 1, 1, 1) end) From 5972acc9dab3e72c6bfafb2fe48d7b0155cad29f Mon Sep 17 00:00:00 2001 From: Licho Date: Mon, 13 Apr 2026 10:15:16 +0200 Subject: [PATCH 05/59] tree with merges --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 376 ++++++++++++++++------ 1 file changed, 272 insertions(+), 104 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index cbc87f7d4c..3cbc9593ec 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -760,76 +760,103 @@ local function NoisyPath(x1, z1, x2, z2, amplitude, seed) return points end --- Generate organic segments for one cable edge. --- Returns list of { x1, z1, x2, z2, width, capacity, isBranch } -local function GenerateOrganicEdge(ex1, ez1, ex2, ez2, capacity) - local segments = {} - local trunkW = GetTrunkWidth(capacity) - local len = sqrt((ex2 - ex1)^2 + (ez2 - ez1)^2) - if len < 2 then return segments end - - local dx = (ex2 - ex1) / len - local dz = (ez2 - ez1) / len - - -- Generate noisy trunk path - local path = NoisyPath(ex1, ez1, ex2, ez2, trunkW * NOISE_AMP, ex1 + ez1) - - -- Emit trunk segments with taper - for i = 1, #path - 1 do - local p1 = path[i] - local p2 = path[i + 1] - local t = (i - 1) / (#path - 1) -- 0..1 along cable - - -- Taper: full width until TAPER_START, then narrow to 60% - local w = trunkW - if t > TAPER_START then - local taperT = (t - TAPER_START) / (1 - TAPER_START) - w = trunkW * (1 - taperT * 0.4) +------------------------------------------------------------------------------------- +-- Tree-level organic router +-- Builds a node graph from edges, DFS from roots. +-- At each junction, heaviest child continues the trunk, others branch off. +-- Shared trunk segments carry combined flow → naturally thicker. +------------------------------------------------------------------------------------- + +local atan2 = math.atan2 + +-- Generate all organic segments from the full set of renderEdges. +-- Returns { segments, pads } +local function GenerateOrganicTree(renderEdges) + local allSegments = {} + local allPads = {} + local padSet = {} + + -- Step 1: Build node graph from edges + -- nodePos[key] = { x, z } + -- nodeChildren[key] = { { key=childKey, cap=capacity, frac=growthFrac }, ... } + -- nodeParent[key] = { key=parentKey } + local nodePos = {} + local nodeChildren = {} -- [posKey] = list of child info + local nodeParent = {} -- [posKey] = parent posKey + local roots = {} -- [posKey] = true + + local function posKey(x, z) + return floor(x) .. ":" .. floor(z) + end + + -- Parse edges into tree structure + for i = 1, #renderEdges do + local e = renderEdges[i] + if e.length > 0 then + local frac = min(1, e.progress / e.length) + if frac > 0.01 then + local pk = posKey(e.px, e.pz) + local ex = e.px + frac * (e.cx - e.px) + local ez = e.pz + frac * (e.cz - e.pz) + local ck = posKey(ex, ez) + + nodePos[pk] = { x = e.px, z = e.pz } + nodePos[ck] = { x = ex, z = ez } + + if not nodeChildren[pk] then nodeChildren[pk] = {} end + nodeChildren[pk][#nodeChildren[pk] + 1] = { + key = ck, cap = max(1, e.capacity), frac = frac, + } + nodeParent[ck] = pk + end end + end - segments[#segments + 1] = { - x1 = p1.x, z1 = p1.z, x2 = p2.x, z2 = p2.z, - width = w, capacity = capacity, isBranch = false, - } + -- Find roots (nodes with no parent) + for pk, _ in pairs(nodePos) do + if not nodeParent[pk] then + roots[pk] = true + end + end - -- Side branches - if i > 1 and i < #path - 1 then - local branchSeed = p1.x * 7.13 + p1.z * 3.77 - if HashUnit(p1.x, p1.z, branchSeed) < BRANCH_CHANCE then - -- Branch direction: perpendicular with random angle offset - local side = (Hash(p1.x, p1.z, branchSeed + 1) > 0) and 1 or -1 - local angle = BRANCH_ANGLE_MIN + HashUnit(p1.x, p1.z, branchSeed + 2) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN) - local bAngle = math.atan2(dz, dx) + side * angle - local bLen = BRANCH_LEN_MIN + HashUnit(p1.x, p1.z, branchSeed + 3) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN) - local bx2 = p1.x + cos(bAngle) * bLen - local bz2 = p1.z + sin(bAngle) * bLen - local bw = w * BRANCH_WIDTH - - -- Noisy branch path (fewer segments) - local bPath = NoisyPath(p1.x, p1.z, bx2, bz2, bw * 0.8, branchSeed + 10) - for bi = 1, #bPath - 1 do - local bp1 = bPath[bi] - local bp2 = bPath[bi + 1] - local bt = bi / (#bPath - 1) - local bwTaper = bw * (1 - bt * 0.6) -- taper to 40% at tip - - segments[#segments + 1] = { - x1 = bp1.x, z1 = bp1.z, x2 = bp2.x, z2 = bp2.z, - width = bwTaper, capacity = capacity * 0.3, isBranch = true, - } + -- Step 2: DFS routing — at each node, trunk continues toward heaviest child + local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch) + local path = NoisyPath(x1, z1, x2, z2, widthStart * NOISE_AMP, seed) + for pi = 1, #path - 1 do + local p1 = path[pi] + local p2 = path[pi + 1] + local t = (pi - 1) / max(1, #path - 2) + local w = widthStart + t * (widthEnd - widthStart) + allSegments[#allSegments + 1] = { + x1 = p1.x, z1 = p1.z, x2 = p2.x, z2 = p2.z, + width = w, capacity = capacity, isBranch = isBranch, + } - -- Sub-twigs - if bi > 1 and bi < #bPath - 1 and HashUnit(bp1.x, bp1.z, branchSeed + 20 + bi) < TWIG_CHANCE then - local tSide = (Hash(bp1.x, bp1.z, branchSeed + 30 + bi) > 0) and 1 or -1 - local tAngle = bAngle + tSide * (0.3 + HashUnit(bp1.x, bp1.z, branchSeed + 40 + bi) * 0.8) - local tLen = TWIG_LEN_MIN + HashUnit(bp1.x, bp1.z, branchSeed + 50 + bi) * (TWIG_LEN_MAX - TWIG_LEN_MIN) - local tx2 = bp1.x + cos(tAngle) * tLen - local tz2 = bp1.z + sin(tAngle) * tLen - local tw = bwTaper * TWIG_WIDTH - - segments[#segments + 1] = { - x1 = bp1.x, z1 = bp1.z, x2 = tx2, z2 = tz2, - width = tw, capacity = capacity * 0.1, isBranch = true, + -- Decorative side twigs (on both trunks and branches, smaller on branches) + if pi > 1 and pi < #path - 1 then + local tseed = p1.x * 7.13 + p1.z * 3.77 + local chance = isBranch and (BRANCH_CHANCE * 0.5) or BRANCH_CHANCE + local lenScale = isBranch and 0.6 or 1.0 + if HashUnit(p1.x, p1.z, tseed) < chance then + local dx = x2 - x1 + local dz = z2 - z1 + local baseAngle = atan2(dz, dx) + local side = (Hash(p1.x, p1.z, tseed + 1) > 0) and 1 or -1 + local angle = baseAngle + side * (BRANCH_ANGLE_MIN + HashUnit(p1.x, p1.z, tseed + 2) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN)) + local bLen = (BRANCH_LEN_MIN + HashUnit(p1.x, p1.z, tseed + 3) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN)) * lenScale + local bx2 = p1.x + cos(angle) * bLen + local bz2 = p1.z + sin(angle) * bLen + local bw = w * BRANCH_WIDTH * (isBranch and 0.6 or 1.0) + + local twigPath = NoisyPath(p1.x, p1.z, bx2, bz2, bw * 0.6, tseed + 10) + for ti = 1, #twigPath - 1 do + local tp1 = twigPath[ti] + local tp2 = twigPath[ti + 1] + local tt = ti / max(1, #twigPath - 1) + allSegments[#allSegments + 1] = { + x1 = tp1.x, z1 = tp1.z, x2 = tp2.x, z2 = tp2.z, + width = bw * (1 - tt * 0.7), + capacity = capacity * 0.1, isBranch = true, } end end @@ -837,7 +864,183 @@ local function GenerateOrganicEdge(ex1, ez1, ex2, ez2, capacity) end end - return segments + -- Normalize angle to [-pi, pi] + 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 + + -- Cluster children by direction. Children within MERGE_ANGLE share a stem. + local MERGE_ANGLE = 0.8 -- ~45 degrees + local STEM_FRACTION = 0.35 -- shared stem = 35% of min child distance + + local function clusterByDirection(pos, children) + -- Compute angle to each child + for i = 1, #children do + local cpos = nodePos[children[i].key] + if cpos then + children[i].angle = atan2(cpos.z - pos.z, cpos.x - pos.x) + children[i].dist = sqrt((cpos.x - pos.x)^2 + (cpos.z - pos.z)^2) + end + end + + -- Sort by angle + table.sort(children, function(a, b) return (a.angle or 0) < (b.angle or 0) end) + + -- Greedy clustering: consecutive children within MERGE_ANGLE + local clusters = {} + local current = { children[1] } + for i = 2, #children do + local diff = abs(normalizeAngle(children[i].angle - children[i-1].angle)) + if diff < MERGE_ANGLE then + current[#current + 1] = children[i] + else + clusters[#clusters + 1] = current + current = { children[i] } + end + end + clusters[#clusters + 1] = current + + -- Also check wrap-around (first and last might be close) + if #clusters > 1 then + local first = clusters[1] + local last = clusters[#clusters] + local diff = abs(normalizeAngle(first[1].angle - last[#last].angle)) + if diff < MERGE_ANGLE then + -- Merge last into first + for i = 1, #last do + first[#first + 1] = last[i] + end + clusters[#clusters] = nil + end + end + + return clusters + end + + local function routeNode(pk, incomingAngle) + local pos = nodePos[pk] + if not pos then return end + local children = nodeChildren[pk] + if not children or #children == 0 then return end + + -- Total capacity + local totalCap = 0 + for i = 1, #children do totalCap = totalCap + children[i].cap end + local trunkW = GetTrunkWidth(totalCap) + + -- Single child: just route directly + if #children == 1 then + local child = children[1] + local cpos = nodePos[child.key] + if cpos then + emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, + trunkW, GetTrunkWidth(child.cap), totalCap, + pos.x + pos.z, false) + routeNode(child.key, atan2(cpos.z - pos.z, cpos.x - pos.x)) + end + return + end + + -- Multiple children: cluster by direction and route with shared stems + local clusters = clusterByDirection(pos, children) + + for ci = 1, #clusters do + local cluster = clusters[ci] + + if #cluster == 1 then + -- Solo child in this direction: branch directly + local child = cluster[1] + local cpos = nodePos[child.key] + if cpos then + local branchW = GetTrunkWidth(child.cap) + emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, + min(branchW * 1.3, trunkW * 0.7), branchW * 0.8, + child.cap, pos.x * 3.7 + pos.z * 1.3 + ci, #clusters > 1) + routeNode(child.key, child.angle or 0) + end + else + -- Multiple children in similar direction: shared stem then split + -- Circular mean for angle (handles wraparound correctly) + local avgCos = 0 + local avgSin = 0 + local clusterCap = 0 + local minDist = math.huge + for i = 1, #cluster do + local a = cluster[i].angle or 0 + avgCos = avgCos + cos(a) + avgSin = avgSin + sin(a) + clusterCap = clusterCap + cluster[i].cap + if cluster[i].dist and cluster[i].dist < minDist then + minDist = cluster[i].dist + end + end + local avgAngle = atan2(avgSin, avgCos) + local stemLen = min(minDist * STEM_FRACTION, 120) + + -- Shared stem from node in average direction + local stemX = pos.x + cos(avgAngle) * stemLen + local stemZ = pos.z + sin(avgAngle) * stemLen + local stemW = GetTrunkWidth(clusterCap) + + emitNoisyPath(pos.x, pos.z, stemX, stemZ, + stemW, stemW * 0.9, clusterCap, + pos.x + pos.z + ci * 7.3, false) + + -- From stem tip, individual branches to each child + -- Sort cluster by capacity for nice ordering + table.sort(cluster, function(a, b) return a.cap > b.cap end) + + for i = 1, #cluster do + local child = cluster[i] + local cpos = nodePos[child.key] + if cpos then + local branchW = GetTrunkWidth(child.cap) + local startW = min(branchW * 1.2, stemW * 0.6) + emitNoisyPath(stemX, stemZ, cpos.x, cpos.z, + startW, branchW * 0.7, child.cap, + stemX * 2.1 + stemZ * 5.3 + i, i > 1) + routeNode(child.key, atan2(cpos.z - stemZ, cpos.x - stemX)) + end + end + end + end + end + + -- Step 3: Route from each root + for pk, _ in pairs(roots) do + local pos = nodePos[pk] + if pos then + -- Add root pad + local children = nodeChildren[pk] + local totalCap = 0 + if children then + for i = 1, #children do totalCap = totalCap + children[i].cap end + end + local w = GetTrunkWidth(max(1, totalCap)) + local pkey = posKey(pos.x, pos.z) + if not padSet[pkey] then + padSet[pkey] = true + allPads[#allPads + 1] = { cx = pos.x, cz = pos.z, radius = w * 1.5 } + end + + routeNode(pk, 0) + end + end + + -- Add pads at all leaf nodes + for pk, pos in pairs(nodePos) do + if not nodeChildren[pk] or #nodeChildren[pk] == 0 then + local pkey = posKey(pos.x, pos.z) + if not padSet[pkey] then + padSet[pkey] = true + allPads[#allPads + 1] = { cx = pos.x, cz = pos.z, radius = GetTrunkWidth(1) * 1.2 } + end + end + end + + return allSegments, allPads end ------------------------------------------------------------------------------------- @@ -903,43 +1106,8 @@ function gadget:DrawGenesis() spSetMapSquareTexture(sq.sx, sq.sz, "") end - -- Generate organic tree segments from all edges - local allSegments = {} - local allPads = {} - local padSet = {} - - for i = 1, #renderEdges do - local e = renderEdges[i] - if e.length > 0 then - local frac = min(1, e.progress / e.length) - if frac > 0.01 then - local ex = e.px + frac * (e.cx - e.px) - local ez = e.pz + frac * (e.cz - e.pz) - local cap = max(1, e.capacity) - - -- Generate organic noisy path with branches - local segs = GenerateOrganicEdge(e.px, e.pz, ex, ez, cap) - for j = 1, #segs do - allSegments[#allSegments + 1] = segs[j] - end - - -- Pads at endpoints - local w = GetTrunkWidth(cap) - local pkey = floor(e.px) .. "," .. floor(e.pz) - if not padSet[pkey] then - padSet[pkey] = true - allPads[#allPads + 1] = { cx = e.px, cz = e.pz, radius = w * 1.5 } - end - if frac >= 0.99 then - local ckey = floor(e.cx) .. "," .. floor(e.cz) - if not padSet[ckey] then - padSet[ckey] = true - allPads[#allPads + 1] = { cx = e.cx, cz = e.cz, radius = w * 1.2 } - end - end - end - end - end + -- Generate organic tree from all edges (tree-level routing with merging) + local allSegments, allPads = GenerateOrganicTree(renderEdges) if #allSegments == 0 then needsRedraw = false From adbeac91beba189dfdea7b6a1ae83572cfa46476 Mon Sep 17 00:00:00 2001 From: Licho Date: Mon, 13 Apr 2026 11:39:06 +0200 Subject: [PATCH 06/59] texture experiment --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 782 ++++++++++------------ 1 file changed, 352 insertions(+), 430 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 3cbc9593ec..b8eaeb7d37 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -605,21 +605,14 @@ end else -- UNSYNCED ------------------------------------------------------------------------------------- --- UNSYNCED — PCB-style cable rendering onto ground texture +-- UNSYNCED — Shader-based cable rendering via DrawWorldPreUnit +-- Cables are drawn as quad strips projected onto ground height. +-- Fragment shader: procedural organic texture, LOS-gated animation. ------------------------------------------------------------------------------------- -local glTexture = gl.Texture -local glCreateTexture = gl.CreateTexture -local glColor = gl.Color -local glTexRect = gl.TexRect -local glResetState = gl.ResetState -local glResetMatrices = gl.ResetMatrices -local glVertex = gl.Vertex - -local spGetMapSquareTexture = Spring.GetMapSquareTexture -local spSetMapSquareTexture = Spring.SetMapSquareTexture -local spGetMyAllyTeamID = Spring.GetMyAllyTeamID -local spGetSpectatingState = Spring.GetSpectatingState +local spGetMyAllyTeamID = Spring.GetMyAllyTeamID +local spGetSpectatingState = Spring.GetSpectatingState +local spGetGroundHeight = Spring.GetGroundHeight local floor = math.floor local sqrt = math.sqrt @@ -629,108 +622,66 @@ 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 SQUARE_SIZE = 1024 -local SQUARES_X = MAP_WIDTH / SQUARE_SIZE -local SQUARES_Z = MAP_HEIGHT / SQUARE_SIZE +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") ------------------------------------------------------------------------------------- --- Organic tree style config +-- Config ------------------------------------------------------------------------------------- local MIN_TRUNK_WIDTH = 4 local MAX_TRUNK_WIDTH = 20 local MAX_CAPACITY_REF = 100 -local PAD_SEGMENTS = 10 - --- Noise & branching -local SEG_LENGTH = 16 -- subdivide cables every N elmos -local NOISE_AMP = 0.6 -- noise amplitude as fraction of width -local BRANCH_CHANCE = 0.25 -- chance of side branch per segment -local BRANCH_LEN_MIN = 15 -- min branch length (elmos) -local BRANCH_LEN_MAX = 50 -- max branch length -local BRANCH_ANGLE_MIN = 0.4 -- min angle offset (radians, ~23°) -local BRANCH_ANGLE_MAX = 1.1 -- max angle offset (radians, ~63°) -local BRANCH_WIDTH = 0.5 -- branch width as fraction of parent -local TWIG_CHANCE = 0.3 -- chance of sub-branch from a branch -local TWIG_LEN_MIN = 8 -local TWIG_LEN_MAX = 25 -local TWIG_WIDTH = 0.4 -local TAPER_START = 0.7 -- start tapering at this fraction along cable - --- Colors (organic: dark bark border, greenish/amber glow) -local BARK_COLOR = { 0.06, 0.04, 0.02, 0.90 } -local INNER_COLOR = { 0.20, 0.55, 0.15, 0.85 } -- green energy -local INNER_COLOR_HIGH = { 0.50, 0.80, 0.20, 0.90 } -- bright for high capacity -local PAD_BARK_COLOR = { 0.08, 0.05, 0.02, 0.92 } -local PAD_INNER_COLOR = { 0.25, 0.60, 0.18, 0.90 } + +local SEG_LENGTH = 10 -- shorter = smoother curves +local NOISE_AMP = 0.6 +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 ------------------------------------------------------------------------------------- -- State ------------------------------------------------------------------------------------- -local squareFBOs = {} local renderEdges = {} local edgesByAllyTeam = {} local lastVersions = {} -local needsRedraw = false -local drawnSquares = {} - -------------------------------------------------------------------------------------- --- Helpers -------------------------------------------------------------------------------------- +local needsRebuild = false -local function SquareKey(sx, sz) - return sx * 10000 + sz -end - -local function GetTraceWidth(capacity) - local t = min(1, capacity / MAX_CAPACITY_REF) - return MIN_TRACE_WIDTH + t * (MAX_TRACE_WIDTH - MIN_TRACE_WIDTH) -end - --- Get trace color based on capacity (copper tint shifts brighter with more flow) -local function GetTraceColor(capacity) - local t = min(1, capacity / MAX_CAPACITY_REF) - return 0.55 + t * 0.35, -- R: warm copper to bright gold - 0.40 + t * 0.30, -- G - 0.12 + t * 0.15, -- B - 0.95 -end +local cableShader +local cableVAO +local numCableVerts = 0 ------------------------------------------------------------------------------------- --- PCB routing: Manhattan + 45° chamfer --- Returns list of segments { {x1, z1, x2, z2}, ... } -------------------------------------------------------------------------------------- - -------------------------------------------------------------------------------------- --- Deterministic noise: hash-based pseudo-random from position --- Returns value in [-1, 1], stable for same inputs across redraws +-- 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 -- [-1, 1] + return (h - floor(h)) * 2 - 1 end -local function HashUnit(x, z, seed) -- [0, 1] +local function HashUnit(x, z, seed) return (Hash(x, z, seed) + 1) * 0.5 end -------------------------------------------------------------------------------------- --- Organic tree path generator --- Takes a cable edge and produces many small noisy segments + side branches. -------------------------------------------------------------------------------------- - local function GetTrunkWidth(capacity) local t = min(1, capacity / MAX_CAPACITY_REF) return MIN_TRUNK_WIDTH + t * (MAX_TRUNK_WIDTH - MIN_TRUNK_WIDTH) end --- Generate noisy path points along a line from (x1,z1) to (x2,z2) --- Returns array of {x, z} waypoints with perpendicular noise local function NoisyPath(x1, z1, x2, z2, amplitude, seed) local dx = x2 - x1 local dz = z2 - z1 @@ -738,22 +689,17 @@ local function NoisyPath(x1, z1, x2, z2, amplitude, seed) 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 -- perpendicular + local nx = -dz / len local nz = dx / len - local points = {} for i = 0, steps do local t = i / steps local px = x1 + t * dx local pz = z1 + t * dz - - -- No noise at endpoints (connect cleanly to pads) 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) * amplitude * noiseScale points[#points + 1] = { x = px + nx * n, z = pz + nz * n } end @@ -761,35 +707,33 @@ local function NoisyPath(x1, z1, x2, z2, amplitude, seed) end ------------------------------------------------------------------------------------- --- Tree-level organic router --- Builds a node graph from edges, DFS from roots. --- At each junction, heaviest child continues the trunk, others branch off. --- Shared trunk segments carry combined flow → naturally thicker. +-- Organic tree router (same logic, outputs vertex data for VBO) ------------------------------------------------------------------------------------- -local atan2 = math.atan2 +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 + +-- Build all cable geometry from renderEdges, return flat vertex array. +-- Paths are converted to smooth triangle strips with averaged normals at junctions. +local function BuildCableVertices() + if #renderEdges == 0 then return {}, 0 end + + -- Each path = { points = { {x,z}, ... }, widths = { w, ... }, capacity, isBranch } + local allPaths = {} --- Generate all organic segments from the full set of renderEdges. --- Returns { segments, pads } -local function GenerateOrganicTree(renderEdges) - local allSegments = {} - local allPads = {} - local padSet = {} - - -- Step 1: Build node graph from edges - -- nodePos[key] = { x, z } - -- nodeChildren[key] = { { key=childKey, cap=capacity, frac=growthFrac }, ... } - -- nodeParent[key] = { key=parentKey } + -- Same tree-building + routing code as before, but collect into allSegs local nodePos = {} - local nodeChildren = {} -- [posKey] = list of child info - local nodeParent = {} -- [posKey] = parent posKey - local roots = {} -- [posKey] = true + local nodeChildren = {} + local nodeParent = {} + local roots = {} local function posKey(x, z) return floor(x) .. ":" .. floor(z) end - -- Parse edges into tree structure for i = 1, #renderEdges do local e = renderEdges[i] if e.length > 0 then @@ -799,10 +743,8 @@ local function GenerateOrganicTree(renderEdges) local ex = e.px + frac * (e.cx - e.px) local ez = e.pz + frac * (e.cz - e.pz) local ck = posKey(ex, ez) - nodePos[pk] = { x = e.px, z = e.pz } nodePos[ck] = { x = ex, z = ez } - if not nodeChildren[pk] then nodeChildren[pk] = {} end nodeChildren[pk][#nodeChildren[pk] + 1] = { key = ck, cap = max(1, e.capacity), frac = frac, @@ -812,71 +754,64 @@ local function GenerateOrganicTree(renderEdges) end end - -- Find roots (nodes with no parent) for pk, _ in pairs(nodePos) do - if not nodeParent[pk] then - roots[pk] = true - end + if not nodeParent[pk] then roots[pk] = true end end - -- Step 2: DFS routing — at each node, trunk continues toward heaviest child local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch) local path = NoisyPath(x1, z1, x2, z2, widthStart * NOISE_AMP, seed) - for pi = 1, #path - 1 do + local widths = {} + for pi = 1, #path do + local t = (pi - 1) / max(1, #path - 1) + widths[pi] = widthStart + t * (widthEnd - widthStart) + end + allPaths[#allPaths + 1] = { + points = path, widths = widths, + capacity = capacity, isBranch = isBranch and 1 or 0, + } + -- Twigs: spawn from ribbon edge, not center + for pi = 2, #path - 1 do local p1 = path[pi] - local p2 = path[pi + 1] - local t = (pi - 1) / max(1, #path - 2) - local w = widthStart + t * (widthEnd - widthStart) - allSegments[#allSegments + 1] = { - x1 = p1.x, z1 = p1.z, x2 = p2.x, z2 = p2.z, - width = w, capacity = capacity, isBranch = isBranch, - } - - -- Decorative side twigs (on both trunks and branches, smaller on branches) - if pi > 1 and pi < #path - 1 then - local tseed = p1.x * 7.13 + p1.z * 3.77 - local chance = isBranch and (BRANCH_CHANCE * 0.5) or BRANCH_CHANCE - local lenScale = isBranch and 0.6 or 1.0 - if HashUnit(p1.x, p1.z, tseed) < chance then - local dx = x2 - x1 - local dz = z2 - z1 - local baseAngle = atan2(dz, dx) - local side = (Hash(p1.x, p1.z, tseed + 1) > 0) and 1 or -1 - local angle = baseAngle + side * (BRANCH_ANGLE_MIN + HashUnit(p1.x, p1.z, tseed + 2) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN)) - local bLen = (BRANCH_LEN_MIN + HashUnit(p1.x, p1.z, tseed + 3) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN)) * lenScale - local bx2 = p1.x + cos(angle) * bLen - local bz2 = p1.z + sin(angle) * bLen - local bw = w * BRANCH_WIDTH * (isBranch and 0.6 or 1.0) - - local twigPath = NoisyPath(p1.x, p1.z, bx2, bz2, bw * 0.6, tseed + 10) - for ti = 1, #twigPath - 1 do - local tp1 = twigPath[ti] - local tp2 = twigPath[ti + 1] - local tt = ti / max(1, #twigPath - 1) - allSegments[#allSegments + 1] = { - x1 = tp1.x, z1 = tp1.z, x2 = tp2.x, z2 = tp2.z, - width = bw * (1 - tt * 0.7), - capacity = capacity * 0.1, isBranch = true, - } - end + local w = widths[pi] + local tseed = p1.x * 7.13 + p1.z * 3.77 + local chance = isBranch and (BRANCH_CHANCE * 0.5) or BRANCH_CHANCE + local lenScale = isBranch and 0.6 or 1.0 + if HashUnit(p1.x, p1.z, tseed) < chance then + local dx = x2 - x1 + local dz = z2 - z1 + local pathLen = sqrt(dx * dx + dz * dz) + if pathLen < 1 then pathLen = 1 end + local baseAngle = atan2(dz, dx) + local side = (Hash(p1.x, p1.z, tseed + 1) > 0) and 1 or -1 + local angle = baseAngle + side * (BRANCH_ANGLE_MIN + HashUnit(p1.x, p1.z, tseed + 2) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN)) + local bLen = (BRANCH_LEN_MIN + HashUnit(p1.x, p1.z, tseed + 3) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN)) * lenScale + + -- Offset start point to ribbon edge (perpendicular to path direction) + local perpX = -dz / pathLen * side + local perpZ = dx / pathLen * side + local edgeX = p1.x + perpX * w * 0.45 + local edgeZ = p1.z + perpZ * w * 0.45 + + local bx2 = edgeX + cos(angle) * bLen + local bz2 = edgeZ + sin(angle) * bLen + local bw = w * BRANCH_WIDTH * (isBranch and 0.6 or 1.0) + local twigPts = NoisyPath(edgeX, edgeZ, bx2, bz2, bw * 0.6, tseed + 10) + local twigWidths = {} + -- Start at parent width, taper to thin tip + twigWidths[1] = min(bw, w * 0.4) + for ti = 2, #twigPts do + local tt = (ti - 1) / max(1, #twigPts - 1) + twigWidths[ti] = twigWidths[1] * (1 - tt * 0.8) end + allPaths[#allPaths + 1] = { + points = twigPts, widths = twigWidths, + capacity = capacity, isBranch = 1, -- same capacity as parent for color match + } end end end - -- Normalize angle to [-pi, pi] - 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 - - -- Cluster children by direction. Children within MERGE_ANGLE share a stem. - local MERGE_ANGLE = 0.8 -- ~45 degrees - local STEM_FRACTION = 0.35 -- shared stem = 35% of min child distance - local function clusterByDirection(pos, children) - -- Compute angle to each child for i = 1, #children do local cpos = nodePos[children[i].key] if cpos then @@ -884,16 +819,11 @@ local function GenerateOrganicTree(renderEdges) children[i].dist = sqrt((cpos.x - pos.x)^2 + (cpos.z - pos.z)^2) end end - - -- Sort by angle table.sort(children, function(a, b) return (a.angle or 0) < (b.angle or 0) end) - - -- Greedy clustering: consecutive children within MERGE_ANGLE local clusters = {} local current = { children[1] } for i = 2, #children do - local diff = abs(normalizeAngle(children[i].angle - children[i-1].angle)) - if diff < MERGE_ANGLE then + if abs(normalizeAngle(children[i].angle - children[i-1].angle)) < MERGE_ANGLE then current[#current + 1] = children[i] else clusters[#clusters + 1] = current @@ -901,342 +831,311 @@ local function GenerateOrganicTree(renderEdges) end end clusters[#clusters + 1] = current - - -- Also check wrap-around (first and last might be close) if #clusters > 1 then - local first = clusters[1] - local last = clusters[#clusters] - local diff = abs(normalizeAngle(first[1].angle - last[#last].angle)) - if diff < MERGE_ANGLE then - -- Merge last into first - for i = 1, #last do - first[#first + 1] = last[i] - end + local first, last = clusters[1], clusters[#clusters] + if abs(normalizeAngle(first[1].angle - last[#last].angle)) < MERGE_ANGLE then + for i = 1, #last do first[#first + 1] = last[i] end clusters[#clusters] = nil end end - return clusters end - local function routeNode(pk, incomingAngle) + local function routeNode(pk) local pos = nodePos[pk] if not pos then return end local children = nodeChildren[pk] if not children or #children == 0 then return end - -- Total capacity local totalCap = 0 for i = 1, #children do totalCap = totalCap + children[i].cap end local trunkW = GetTrunkWidth(totalCap) - -- Single child: just route directly if #children == 1 then local child = children[1] local cpos = nodePos[child.key] if cpos then - emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, - trunkW, GetTrunkWidth(child.cap), totalCap, - pos.x + pos.z, false) - routeNode(child.key, atan2(cpos.z - pos.z, cpos.x - pos.x)) + emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, trunkW, GetTrunkWidth(child.cap), totalCap, pos.x + pos.z, false) + routeNode(child.key) end return end - -- Multiple children: cluster by direction and route with shared stems local clusters = clusterByDirection(pos, children) - for ci = 1, #clusters do local cluster = clusters[ci] - if #cluster == 1 then - -- Solo child in this direction: branch directly local child = cluster[1] local cpos = nodePos[child.key] if cpos then - local branchW = GetTrunkWidth(child.cap) - emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, - min(branchW * 1.3, trunkW * 0.7), branchW * 0.8, - child.cap, pos.x * 3.7 + pos.z * 1.3 + ci, #clusters > 1) - routeNode(child.key, child.angle or 0) + local bw = GetTrunkWidth(child.cap) + emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, min(bw * 1.3, trunkW * 0.7), bw * 0.8, child.cap, pos.x * 3.7 + pos.z * 1.3 + ci, #clusters > 1) + routeNode(child.key) end else - -- Multiple children in similar direction: shared stem then split - -- Circular mean for angle (handles wraparound correctly) - local avgCos = 0 - local avgSin = 0 - local clusterCap = 0 - local minDist = math.huge + local avgCos, avgSin, clusterCap, minDist = 0, 0, 0, math.huge for i = 1, #cluster do local a = cluster[i].angle or 0 avgCos = avgCos + cos(a) avgSin = avgSin + sin(a) clusterCap = clusterCap + cluster[i].cap - if cluster[i].dist and cluster[i].dist < minDist then - minDist = cluster[i].dist - end + if cluster[i].dist and cluster[i].dist < minDist then minDist = cluster[i].dist end end local avgAngle = atan2(avgSin, avgCos) local stemLen = min(minDist * STEM_FRACTION, 120) - - -- Shared stem from node in average direction local stemX = pos.x + cos(avgAngle) * stemLen local stemZ = pos.z + sin(avgAngle) * stemLen local stemW = GetTrunkWidth(clusterCap) - - emitNoisyPath(pos.x, pos.z, stemX, stemZ, - stemW, stemW * 0.9, clusterCap, - pos.x + pos.z + ci * 7.3, false) - - -- From stem tip, individual branches to each child - -- Sort cluster by capacity for nice ordering + emitNoisyPath(pos.x, pos.z, stemX, stemZ, stemW, stemW * 0.9, clusterCap, pos.x + pos.z + ci * 7.3, false) table.sort(cluster, function(a, b) return a.cap > b.cap end) - for i = 1, #cluster do local child = cluster[i] local cpos = nodePos[child.key] if cpos then - local branchW = GetTrunkWidth(child.cap) - local startW = min(branchW * 1.2, stemW * 0.6) - emitNoisyPath(stemX, stemZ, cpos.x, cpos.z, - startW, branchW * 0.7, child.cap, - stemX * 2.1 + stemZ * 5.3 + i, i > 1) - routeNode(child.key, atan2(cpos.z - stemZ, cpos.x - stemX)) + local bw = GetTrunkWidth(child.cap) + emitNoisyPath(stemX, stemZ, cpos.x, cpos.z, min(bw * 1.2, stemW * 0.6), bw * 0.7, child.cap, stemX * 2.1 + stemZ * 5.3 + i, i > 1) + routeNode(child.key) end end end end end - -- Step 3: Route from each root - for pk, _ in pairs(roots) do - local pos = nodePos[pk] - if pos then - -- Add root pad - local children = nodeChildren[pk] - local totalCap = 0 - if children then - for i = 1, #children do totalCap = totalCap + children[i].cap end + for pk, _ in pairs(roots) do routeNode(pk) end + + -- Convert paths to smooth triangle strips. + -- At each waypoint, compute perpendicular averaged from incoming+outgoing directions. + -- Each pair of consecutive waypoints forms a quad (2 triangles, 6 verts). + local verts = {} + local vertCount = 0 + + for pi = 1, #allPaths do + local path = allPaths[pi] + local pts = path.points + local wds = path.widths + local cap = path.capacity + local branch = path.isBranch + + if #pts >= 2 then + + -- Compute averaged perpendicular at each waypoint + local perps = {} -- { {nx, nz}, ... } perpendicular directions at each point + for i = 1, #pts do + local px, pz = 0, 0 + if i > 1 then + local dx = pts[i].x - pts[i-1].x + local dz = pts[i].z - pts[i-1].z + local len = sqrt(dx*dx + dz*dz) + if len > 0.01 then + px = px + (-dz/len) + pz = pz + ( dx/len) + end end - local w = GetTrunkWidth(max(1, totalCap)) - local pkey = posKey(pos.x, pos.z) - if not padSet[pkey] then - padSet[pkey] = true - allPads[#allPads + 1] = { cx = pos.x, cz = pos.z, radius = w * 1.5 } + if i < #pts then + local dx = pts[i+1].x - pts[i].x + local dz = pts[i+1].z - pts[i].z + local len = sqrt(dx*dx + dz*dz) + if len > 0.01 then + px = px + (-dz/len) + pz = pz + ( dx/len) + end + end + local plen = sqrt(px*px + pz*pz) + if plen > 0.01 then + perps[i] = { nx = px/plen, nz = pz/plen } + else + perps[i] = { nx = 0, nz = 1 } end + end - routeNode(pk, 0) + -- Compute left/right vertices at each waypoint + local lefts = {} + local rights = {} + for i = 1, #pts do + local hw = (wds[i] or wds[#wds] or 5) * 0.55 + local p = perps[i] + local y = spGetGroundHeight(pts[i].x, pts[i].z) + 2 + lefts[i] = { x = pts[i].x - p.nx * hw, y = y, z = pts[i].z - p.nz * hw } + rights[i] = { x = pts[i].x + p.nx * hw, y = y, z = pts[i].z + p.nz * hw } end - end - -- Add pads at all leaf nodes - for pk, pos in pairs(nodePos) do - if not nodeChildren[pk] or #nodeChildren[pk] == 0 then - local pkey = posKey(pos.x, pos.z) - if not padSet[pkey] then - padSet[pkey] = true - allPads[#allPads + 1] = { cx = pos.x, cz = pos.z, radius = GetTrunkWidth(1) * 1.2 } - end + -- Emit triangles for each consecutive pair (quad = 2 tris) + for i = 1, #pts - 1 do + local L1, R1 = lefts[i], rights[i] + local L2, R2 = lefts[i+1], rights[i+1] + + -- Tri 1: L1, R1, R2 + verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z + verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 + verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z + verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 + verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z + verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 + + -- Tri 2: L1, R2, L2 + verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z + verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 + verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z + verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 + verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z + verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 + + vertCount = vertCount + 6 end + + end -- if #pts >= 2 end - return allSegments, allPads + return verts, vertCount end ------------------------------------------------------------------------------------- --- Drawing primitives (all in FBO NDC space) +-- Shader sources ------------------------------------------------------------------------------------- --- Convert world coords to FBO NDC [-1, 1] for a given square -local function MakeW2T(sqX, sqZ) - return function(wx, wz) - return 2 * (wx - sqX) / SQUARE_SIZE - 1, - 2 * (wz - sqZ) / SQUARE_SIZE - 1 - end -end +local cableVSSrc = [[ +#version 420 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +layout (location = 0) in vec3 vertPos; // x, y, z in world space +layout (location = 1) in vec3 vertData; // capacity, isBranch, width + +out DataVS { + vec3 worldPos; + float capacity; + float isBranch; + float width; +}; + +//__ENGINEUNIFORMBUFFERDEFS__ + +void main() { + worldPos = vertPos; + capacity = vertData.x; + isBranch = vertData.y; + width = vertData.z; + gl_Position = cameraViewProj * vec4(vertPos, 1.0); +} +]] + +local cableFSSrc = [[ +#version 330 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +uniform sampler2D infoTex; +uniform float gameTime; + +in DataVS { + vec3 worldPos; + float capacity; + float isBranch; + float width; +}; + +//__ENGINEUNIFORMBUFFERDEFS__ + +out vec4 fragColor; + +// Noise for procedural texture +float hash(vec2 p) { + float h = dot(p, vec2(12.9898, 78.233)); + return fract(sin(h) * 43758.5453); +} + +void main() { + // LOS check + vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; + float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); + float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); + + // Unexplored: discard + if (losState < 0.05) discard; + + // Capacity-based color + float capT = clamp(capacity / 100.0, 0.0, 1.0); + + // Bark border color (dark) + vec3 barkColor = vec3(0.06, 0.04, 0.02); + // Inner glow: green energy, brighter with capacity + vec3 innerColor = mix(vec3(0.20, 0.55, 0.15), vec3(0.50, 0.80, 0.20), capT); + + // Simple procedural: mostly bark with inner glow + // Use a simple threshold based on noise for organic feel + float n = hash(worldPos.xz * 0.1); + float innerMix = 0.4 + 0.2 * n; + if (isBranch > 0.5) innerMix *= 0.6; + + vec3 baseColor = mix(barkColor, innerColor, innerMix); + + // Animated energy pulse (only in full LOS) + float fullLOS = step(0.7, losState); + float pulse = 0.5 + 0.5 * sin(gameTime * 3.0 + worldPos.x * 0.05 + worldPos.z * 0.05); + baseColor += innerColor * pulse * 0.15 * fullLOS * capT; + + // Dim in radar/previously-seen areas + float dimFactor = mix(0.4, 1.0, smoothstep(0.3, 0.8, losState)); + baseColor *= dimFactor; + + fragColor = vec4(baseColor, 0.9); +} +]] --- Emit a quad for a trace segment (call inside gl.BeginEnd GL.QUADS) -local function EmitTraceQuad(w2t, x1, z1, x2, z2, hw) - local dx = x2 - x1 - local dz = z2 - z1 - local len = sqrt(dx * dx + dz * dz) - if len < 0.5 then return end +------------------------------------------------------------------------------------- +-- Drawing +------------------------------------------------------------------------------------- - local px = -dz / len * hw - local pz = dx / len * hw +function gadget:DrawWorldPreUnit() + if not cableShader or numCableVerts == 0 then return end - local t1x, t1z = w2t(x1 - px, z1 - pz) - local t2x, t2z = w2t(x1 + px, z1 + pz) - local t3x, t3z = w2t(x2 + px, z2 + pz) - local t4x, t4z = w2t(x2 - px, z2 - pz) + cableShader:Activate() + cableShader:SetUniform("gameTime", Spring.GetGameSeconds()) - glVertex(t1x, t1z, 0) - glVertex(t2x, t2z, 0) - glVertex(t3x, t3z, 0) - glVertex(t4x, t4z, 0) -end + gl.Texture(0, "$info") + gl.DepthTest(GL.LEQUAL) + gl.DepthMask(false) + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) --- Emit a filled circle (call inside gl.BeginEnd GL.TRIANGLE_FAN) -local function EmitCircle(w2t, cx, cz, radius) - local tx, tz = w2t(cx, cz) - glVertex(tx, tz, 0) -- center - for i = 0, PAD_SEGMENTS do - local angle = (i / PAD_SEGMENTS) * PI * 2 - local px, pz = w2t(cx + cos(angle) * radius, cz + sin(angle) * radius) - glVertex(px, pz, 0) - end + cableVAO:DrawArrays(GL.TRIANGLES, numCableVerts) + + cableShader:Deactivate() + gl.Texture(0, false) + gl.DepthTest(false) end ------------------------------------------------------------------------------------- --- Drawing hook +-- VBO rebuild ------------------------------------------------------------------------------------- -function gadget:DrawGenesis() - if not needsRedraw then return end - if #renderEdges == 0 then - needsRedraw = false - return - end - - glResetState() - glResetMatrices() - - -- Revert previously modified squares - for _, sq in pairs(drawnSquares) do - spSetMapSquareTexture(sq.sx, sq.sz, "") - end - - -- Generate organic tree from all edges (tree-level routing with merging) - local allSegments, allPads = GenerateOrganicTree(renderEdges) - - if #allSegments == 0 then - needsRedraw = false +local function RebuildVBO() + local verts, vertCount = BuildCableVertices() + if vertCount == 0 then + numCableVerts = 0 return end - -- Collect needed squares (from segments + pads) - local neededSquares = {} - for i = 1, #allSegments do - local s = allSegments[i] - local m = s.width + 60 -- extra margin for branch noise - local minSx = max(0, floor((min(s.x1, s.x2) - m) / SQUARE_SIZE)) - local maxSx = min(SQUARES_X - 1, floor((max(s.x1, s.x2) + m) / SQUARE_SIZE)) - local minSz = max(0, floor((min(s.z1, s.z2) - m) / SQUARE_SIZE)) - local maxSz = min(SQUARES_Z - 1, floor((max(s.z1, s.z2) + m) / SQUARE_SIZE)) - for sx = minSx, maxSx do - for sz = minSz, maxSz do - neededSquares[SquareKey(sx, sz)] = { sx = sx, sz = sz } - end - end + if cableVAO then + -- Recreate + cableVAO = nil end - for i = 1, #allPads do - local p = allPads[i] - local m = p.radius + 5 - local minSx = max(0, floor((p.cx - m) / SQUARE_SIZE)) - local maxSx = min(SQUARES_X - 1, floor((p.cx + m) / SQUARE_SIZE)) - local minSz = max(0, floor((p.cz - m) / SQUARE_SIZE)) - local maxSz = min(SQUARES_Z - 1, floor((p.cz + m) / SQUARE_SIZE)) - for sx = minSx, maxSx do - for sz = minSz, maxSz do - neededSquares[SquareKey(sx, sz)] = { sx = sx, sz = sz } - end - end - end - - -- Render on each needed square - drawnSquares = {} - for key, sq in pairs(neededSquares) do - local sx, sz = sq.sx, sq.sz - - -- Ensure FBO pair exists - if not squareFBOs[sx] then squareFBOs[sx] = {} end - if not squareFBOs[sx][sz] then - local cur = glCreateTexture(SQUARE_SIZE, SQUARE_SIZE, { - fbo = true, min_filter = GL.LINEAR_MIPMAP_NEAREST, - wrap_s = GL.CLAMP_TO_EDGE, wrap_t = GL.CLAMP_TO_EDGE, - }) - local orig = glCreateTexture(SQUARE_SIZE, SQUARE_SIZE, { - fbo = true, - wrap_s = GL.CLAMP_TO_EDGE, wrap_t = GL.CLAMP_TO_EDGE, - }) - if cur and orig then - squareFBOs[sx][sz] = { cur = cur, orig = orig } - else - if cur then gl.DeleteTextureFBO(cur) end - if orig then gl.DeleteTextureFBO(orig) end - end - end - local pair = squareFBOs[sx] and squareFBOs[sx][sz] - if pair then - -- Snapshot orig, copy to cur - spGetMapSquareTexture(sx, sz, 0, pair.orig) - glTexture(pair.orig) - gl.RenderToTexture(pair.cur, function() - glTexRect(-1, 1, 1, -1) - end) - glTexture(false) - - local sqX = sx * SQUARE_SIZE - local sqZ = sz * SQUARE_SIZE - local w2t = MakeW2T(sqX, sqZ) - - gl.RenderToTexture(pair.cur, function() - gl.Texture(false) - - -- Layer 1: Dark bark border (all segments) - glColor(BARK_COLOR[1], BARK_COLOR[2], BARK_COLOR[3], BARK_COLOR[4]) - gl.BeginEnd(GL.QUADS, function() - for i = 1, #allSegments do - local s = allSegments[i] - EmitTraceQuad(w2t, s.x1, s.z1, s.x2, s.z2, s.width * 0.55) - end - end) - - -- Layer 2: Inner glow (energy color, varies by capacity) - gl.BeginEnd(GL.QUADS, function() - for i = 1, #allSegments do - local s = allSegments[i] - local t = min(1, s.capacity / MAX_CAPACITY_REF) - local r = INNER_COLOR[1] + t * (INNER_COLOR_HIGH[1] - INNER_COLOR[1]) - local g = INNER_COLOR[2] + t * (INNER_COLOR_HIGH[2] - INNER_COLOR[2]) - local b = INNER_COLOR[3] + t * (INNER_COLOR_HIGH[3] - INNER_COLOR[3]) - local a = INNER_COLOR[4] - if s.isBranch then a = a * 0.7 end - glColor(r, g, b, a) - EmitTraceQuad(w2t, s.x1, s.z1, s.x2, s.z2, s.width * 0.35) - end - end) - - -- Layer 3: Pad borders (dark bark circles at nodes) - glColor(PAD_BARK_COLOR[1], PAD_BARK_COLOR[2], PAD_BARK_COLOR[3], PAD_BARK_COLOR[4]) - for i = 1, #allPads do - local p = allPads[i] - gl.BeginEnd(GL.TRIANGLE_FAN, function() - EmitCircle(w2t, p.cx, p.cz, p.radius) - end) - end + local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) + if not vbo then return end - -- Layer 4: Pad inner glow - glColor(PAD_INNER_COLOR[1], PAD_INNER_COLOR[2], PAD_INNER_COLOR[3], PAD_INNER_COLOR[4]) - for i = 1, #allPads do - local p = allPads[i] - gl.BeginEnd(GL.TRIANGLE_FAN, function() - EmitCircle(w2t, p.cx, p.cz, p.radius * 0.65) - end) - end + vbo:Define(vertCount, { + { id = 0, name = "vertPos", size = 3 }, + { id = 1, name = "vertData", size = 3 }, + }) + vbo:Upload(verts) - glColor(1, 1, 1, 1) - end) - - -- Apply - gl.GenerateMipmap(pair.cur) - spSetMapSquareTexture(sx, sz, pair.cur) - drawnSquares[key] = sq - end + cableVAO = gl.GetVAO() + if cableVAO then + cableVAO:AttachVertexBuffer(vbo) end - needsRedraw = false + numCableVerts = vertCount + needsRebuild = false end ------------------------------------------------------------------------------------- @@ -1274,33 +1173,56 @@ local function OnCableTreeUpdate() end end - needsRedraw = true + needsRebuild = true end ------------------------------------------------------------------------------------- -- Lifecycle ------------------------------------------------------------------------------------- +function gadget:GameFrame(n) + if needsRebuild and n % 6 == 0 then + RebuildVBO() + end +end + function gadget:Initialize() - if not gl.RenderToTexture then + if not gl.CreateShader or not gl.GetVBO or not gl.GetVAO then + Spring.Echo("[CableTree] Missing GL support, disabling") gadgetHandler:RemoveGadget() return end + + local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() + local vsSrc = cableVSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + local fsSrc = cableFSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) + + cableShader = LuaShader({ + vertex = vsSrc, + fragment = fsSrc, + uniformInt = { + infoTex = 0, + }, + uniformFloat = { + gameTime = 0, + }, + }, "Cable Tree Shader") + + local compiled = cableShader:Initialize() + if not compiled then + Spring.Echo("[CableTree] Shader compilation failed") + gadgetHandler:RemoveGadget() + return + end + gadgetHandler:AddSyncAction("CableTreeUpdate", OnCableTreeUpdate) end function gadget:Shutdown() - for _, sq in pairs(drawnSquares) do - spSetMapSquareTexture(sq.sx, sq.sz, "") - end - for sx, szMap in pairs(squareFBOs) do - for sz, pair in pairs(szMap) do - if pair.cur then gl.DeleteTextureFBO(pair.cur) end - if pair.orig then gl.DeleteTextureFBO(pair.orig) end - end + if cableShader then + cableShader:Finalize() end - squareFBOs = {} - drawnSquares = {} + cableVAO = nil gadgetHandler:RemoveSyncAction("CableTreeUpdate") end From b5dc5554a0887dd9a9b85f7c09ebeabe063d599f Mon Sep 17 00:00:00 2001 From: Licho Date: Mon, 13 Apr 2026 11:56:53 +0200 Subject: [PATCH 07/59] los --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 181 ++++++++++++++++++---- 1 file changed, 152 insertions(+), 29 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index b8eaeb7d37..de1c03ed31 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -660,10 +660,16 @@ local edgesByAllyTeam = {} local lastVersions = {} local needsRebuild = false -local cableShader -local cableVAO +local cableShader -- 3D shader for live cables + snapshot sampling +local cableVAO -- live cable geometry local numCableVerts = 0 +local snapshotTex -- persistent 2D texture (top-down map projection) +local snapshotShader -- simple shader to render cables to snapshot +local SNAPSHOT_SCALE = 4 -- snapshot pixels per elmo (4 = quarter resolution) +local snapshotW, snapshotH +local needsSnapshotUpdate = false + ------------------------------------------------------------------------------------- -- Deterministic noise ------------------------------------------------------------------------------------- @@ -993,13 +999,69 @@ end -- Shader sources ------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------- +-- Snapshot shader: renders cables flat (top-down) into the snapshot texture. +-- Only updates pixels in LOS. Uses $info to mask. +------------------------------------------------------------------------------------- + +local snapVSSrc = [[ +#version 420 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +layout (location = 0) in vec3 vertPos; +layout (location = 1) in vec3 vertData; // capacity, isBranch, width + +uniform vec2 mapDims; // mapSizeX, mapSizeZ + +out float vCapacity; +out float vIsBranch; + +void main() { + vCapacity = vertData.x; + vIsBranch = vertData.y; + // Project to NDC: map (0..mapSizeX, 0..mapSizeZ) -> (-1..1, -1..1) + vec2 ndc = (vertPos.xz / mapDims) * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); +} +]] + +local snapFSSrc = [[ +#version 330 + +in float vCapacity; +in float vIsBranch; + +out vec4 fragColor; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() { + float capT = clamp(vCapacity / 100.0, 0.0, 1.0); + vec3 barkColor = vec3(0.06, 0.04, 0.02); + vec3 innerColor = mix(vec3(0.20, 0.55, 0.15), vec3(0.50, 0.80, 0.20), capT); + + float n = hash(gl_FragCoord.xy * 0.3); + float innerMix = 0.4 + 0.2 * n; + if (vIsBranch > 0.5) innerMix *= 0.6; + + fragColor = vec4(mix(barkColor, innerColor, innerMix), 1.0); +} +]] + +------------------------------------------------------------------------------------- +-- Main 3D shader: draws live cables in LOS, samples snapshot for fog areas +------------------------------------------------------------------------------------- + local cableVSSrc = [[ #version 420 #extension GL_ARB_uniform_buffer_object : require #extension GL_ARB_shading_language_420pack: require -layout (location = 0) in vec3 vertPos; // x, y, z in world space -layout (location = 1) in vec3 vertData; // capacity, isBranch, width +layout (location = 0) in vec3 vertPos; +layout (location = 1) in vec3 vertData; // capacity, isBranch, width out DataVS { vec3 worldPos; @@ -1025,7 +1087,9 @@ local cableFSSrc = [[ #extension GL_ARB_shading_language_420pack: require uniform sampler2D infoTex; +uniform sampler2D snapshotTex; uniform float gameTime; +uniform vec2 mapDims; in DataVS { vec3 worldPos; @@ -1038,47 +1102,48 @@ in DataVS { out vec4 fragColor; -// Noise for procedural texture float hash(vec2 p) { - float h = dot(p, vec2(12.9898, 78.233)); - return fract(sin(h) * 43758.5453); + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } void main() { - // LOS check + // LOS state vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); - // Unexplored: discard - if (losState < 0.05) discard; + // Sample snapshot for this world position + vec2 snapUV = clamp(worldPos.xz / mapDims, vec2(0.0), vec2(1.0)); + vec4 snapColor = texture(snapshotTex, snapUV); - // Capacity-based color - float capT = clamp(capacity / 100.0, 0.0, 1.0); + // Fully unexplored: show snapshot if it has data, otherwise discard + if (losState < 0.05) { + if (snapColor.a < 0.1) discard; + fragColor = vec4(snapColor.rgb * 0.35, snapColor.a * 0.7); + return; + } - // Bark border color (dark) + // In radar/fog: blend snapshot (dimmed) with fading live geometry + float capT = clamp(capacity / 100.0, 0.0, 1.0); vec3 barkColor = vec3(0.06, 0.04, 0.02); - // Inner glow: green energy, brighter with capacity vec3 innerColor = mix(vec3(0.20, 0.55, 0.15), vec3(0.50, 0.80, 0.20), capT); - // Simple procedural: mostly bark with inner glow - // Use a simple threshold based on noise for organic feel float n = hash(worldPos.xz * 0.1); float innerMix = 0.4 + 0.2 * n; if (isBranch > 0.5) innerMix *= 0.6; - vec3 baseColor = mix(barkColor, innerColor, innerMix); + vec3 liveColor = mix(barkColor, innerColor, innerMix); - // Animated energy pulse (only in full LOS) + // Animated pulse (only full LOS) float fullLOS = step(0.7, losState); float pulse = 0.5 + 0.5 * sin(gameTime * 3.0 + worldPos.x * 0.05 + worldPos.z * 0.05); - baseColor += innerColor * pulse * 0.15 * fullLOS * capT; + liveColor += innerColor * pulse * 0.15 * fullLOS * capT; - // Dim in radar/previously-seen areas - float dimFactor = mix(0.4, 1.0, smoothstep(0.3, 0.8, losState)); - baseColor *= dimFactor; + // Blend: full LOS = live color, radar = dimmed live, low = snapshot + float liveFactor = smoothstep(0.3, 0.8, losState); + float dimFactor = mix(0.4, 1.0, liveFactor); - fragColor = vec4(baseColor, 0.9); + fragColor = vec4(liveColor * dimFactor, 0.9); } ]] @@ -1087,12 +1152,30 @@ void main() { ------------------------------------------------------------------------------------- function gadget:DrawWorldPreUnit() - if not cableShader or numCableVerts == 0 then return end + if not cableShader or numCableVerts == 0 or not cableVAO then return end + + -- Update snapshot texture if needed (must happen in a draw callin) + if needsSnapshotUpdate and snapshotTex and snapshotShader then + gl.RenderToTexture(snapshotTex, function() + gl.Clear(GL.COLOR_BUFFER_BIT, 0, 0, 0, 0) + snapshotShader:Activate() + snapshotShader:SetUniform("mapDims", MAP_WIDTH, MAP_HEIGHT) + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + cableVAO:DrawArrays(GL.TRIANGLES, numCableVerts) + snapshotShader:Deactivate() + end) + needsSnapshotUpdate = false + end + -- Draw live 3D cables cableShader:Activate() cableShader:SetUniform("gameTime", Spring.GetGameSeconds()) + cableShader:SetUniform("mapDims", MAP_WIDTH, MAP_HEIGHT) gl.Texture(0, "$info") + if snapshotTex then + gl.Texture(1, snapshotTex) + end gl.DepthTest(GL.LEQUAL) gl.DepthMask(false) gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) @@ -1101,6 +1184,7 @@ function gadget:DrawWorldPreUnit() cableShader:Deactivate() gl.Texture(0, false) + gl.Texture(1, false) gl.DepthTest(false) end @@ -1112,13 +1196,12 @@ local function RebuildVBO() local verts, vertCount = BuildCableVertices() if vertCount == 0 then numCableVerts = 0 + needsRebuild = false return end - if cableVAO then - -- Recreate - cableVAO = nil - end + -- Rebuild VBO/VAO + cableVAO = nil local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) if not vbo then return end @@ -1135,6 +1218,8 @@ local function RebuildVBO() end numCableVerts = vertCount + + needsSnapshotUpdate = true needsRebuild = false end @@ -1187,12 +1272,42 @@ function gadget:GameFrame(n) end function gadget:Initialize() - if not gl.CreateShader or not gl.GetVBO or not gl.GetVAO then + if not gl.CreateShader or not gl.GetVBO or not gl.GetVAO or not gl.RenderToTexture then Spring.Echo("[CableTree] Missing GL support, disabling") gadgetHandler:RemoveGadget() return end + -- Create snapshot texture (map-sized, reduced resolution) + snapshotW = floor(MAP_WIDTH / SNAPSHOT_SCALE) + snapshotH = floor(MAP_HEIGHT / SNAPSHOT_SCALE) + snapshotTex = gl.CreateTexture(snapshotW, snapshotH, { + fbo = true, + min_filter = GL.LINEAR, + mag_filter = GL.LINEAR, + wrap_s = GL.CLAMP_TO_EDGE, + wrap_t = GL.CLAMP_TO_EDGE, + }) + if not snapshotTex then + Spring.Echo("[CableTree] Failed to create snapshot texture") + end + + -- Compile snapshot shader (flat 2D projection) + snapshotShader = LuaShader({ + vertex = snapVSSrc, + fragment = snapFSSrc, + uniformFloat = { + mapDims = 0, + }, + }, "Cable Snapshot Shader") + + local snapCompiled = snapshotShader:Initialize() + if not snapCompiled then + Spring.Echo("[CableTree] Snapshot shader failed to compile") + snapshotShader = nil + end + + -- Compile main 3D cable shader local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() local vsSrc = cableVSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) local fsSrc = cableFSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) @@ -1202,9 +1317,11 @@ function gadget:Initialize() fragment = fsSrc, uniformInt = { infoTex = 0, + snapshotTex = 1, }, uniformFloat = { gameTime = 0, + mapDims = 0, }, }, "Cable Tree Shader") @@ -1222,6 +1339,12 @@ function gadget:Shutdown() if cableShader then cableShader:Finalize() end + if snapshotShader then + snapshotShader:Finalize() + end + if snapshotTex then + gl.DeleteTextureFBO(snapshotTex) + end cableVAO = nil gadgetHandler:RemoveSyncAction("CableTreeUpdate") end From a954e8cb06c33e7e9257f021ac7f8d7be20a6102 Mon Sep 17 00:00:00 2001 From: Licho Date: Mon, 13 Apr 2026 13:32:49 +0200 Subject: [PATCH 08/59] animated cablespawk --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 143 ++++++++++++++++------ 1 file changed, 106 insertions(+), 37 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index de1c03ed31..c921556b7b 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -954,9 +954,17 @@ local function BuildCableVertices() end end - -- Compute left/right vertices at each waypoint + -- Compute left/right vertices + cumulative U distance at each waypoint local lefts = {} local rights = {} + local uDist = {} -- cumulative distance along path + uDist[1] = 0 + for i = 2, #pts do + local dx = pts[i].x - pts[i-1].x + local dz = pts[i].z - pts[i-1].z + uDist[i] = uDist[i-1] + sqrt(dx*dx + dz*dz) + end + for i = 1, #pts do local hw = (wds[i] or wds[#wds] or 5) * 0.55 local p = perps[i] @@ -965,26 +973,33 @@ local function BuildCableVertices() rights[i] = { x = pts[i].x + p.nx * hw, y = y, z = pts[i].z + p.nz * hw } end - -- Emit triangles for each consecutive pair (quad = 2 tris) + -- Emit triangles: 8 floats per vert (pos3 + data3 + uv2) for i = 1, #pts - 1 do local L1, R1 = lefts[i], rights[i] local L2, R2 = lefts[i+1], rights[i+1] + local u1, u2 = uDist[i], uDist[i+1] -- Tri 1: L1, R1, R2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 + verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 + verts[#verts+1]=u1; verts[#verts+1]=1 verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 + verts[#verts+1]=u2; verts[#verts+1]=1 -- Tri 2: L1, R2, L2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 + verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 + verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 + verts[#verts+1]=u2; verts[#verts+1]=-1 vertCount = vertCount + 6 end @@ -1010,9 +1025,10 @@ local snapVSSrc = [[ #extension GL_ARB_shading_language_420pack: require layout (location = 0) in vec3 vertPos; -layout (location = 1) in vec3 vertData; // capacity, isBranch, width +layout (location = 1) in vec3 vertData; +layout (location = 2) in vec2 vertUV; -uniform vec2 mapDims; // mapSizeX, mapSizeZ +uniform vec2 mapDims; out float vCapacity; out float vIsBranch; @@ -1020,7 +1036,6 @@ out float vIsBranch; void main() { vCapacity = vertData.x; vIsBranch = vertData.y; - // Project to NDC: map (0..mapSizeX, 0..mapSizeZ) -> (-1..1, -1..1) vec2 ndc = (vertPos.xz / mapDims) * 2.0 - 1.0; gl_Position = vec4(ndc, 0.0, 1.0); } @@ -1034,20 +1049,12 @@ in float vIsBranch; out vec4 fragColor; -float hash(vec2 p) { - return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); -} - void main() { float capT = clamp(vCapacity / 100.0, 0.0, 1.0); - vec3 barkColor = vec3(0.06, 0.04, 0.02); - vec3 innerColor = mix(vec3(0.20, 0.55, 0.15), vec3(0.50, 0.80, 0.20), capT); - - float n = hash(gl_FragCoord.xy * 0.3); - float innerMix = 0.4 + 0.2 * n; - if (vIsBranch > 0.5) innerMix *= 0.6; - - fragColor = vec4(mix(barkColor, innerColor, innerMix), 1.0); + // Electric blue for snapshot (static, no animation) + vec3 color = mix(vec3(0.15, 0.2, 0.5), vec3(0.4, 0.6, 1.0), capT); + if (vIsBranch > 0.5) color *= 0.5; + fragColor = vec4(color, 1.0); } ]] @@ -1061,13 +1068,15 @@ local cableVSSrc = [[ #extension GL_ARB_shading_language_420pack: require layout (location = 0) in vec3 vertPos; -layout (location = 1) in vec3 vertData; // capacity, isBranch, width +layout (location = 1) in vec3 vertData; +layout (location = 2) in vec2 vertUV; // u = along cable, v = -1(left) to +1(right) out DataVS { vec3 worldPos; float capacity; float isBranch; float width; + vec2 cableUV; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1077,6 +1086,7 @@ void main() { capacity = vertData.x; isBranch = vertData.y; width = vertData.z; + cableUV = vertUV; gl_Position = cameraViewProj * vec4(vertPos, 1.0); } ]] @@ -1096,6 +1106,7 @@ in DataVS { float capacity; float isBranch; float width; + vec2 cableUV; // u = distance along cable (elmos), v = -1(left) to +1(right) }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1106,44 +1117,101 @@ float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm(vec2 p) { + float val = 0.0; + float amp = 0.5; + for (int i = 0; i < 3; i++) { + val += amp * noise(p); + p *= 2.1; + amp *= 0.5; + } + return val; +} + void main() { - // LOS state + // LOS vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); - // Sample snapshot for this world position + // Snapshot ghosting vec2 snapUV = clamp(worldPos.xz / mapDims, vec2(0.0), vec2(1.0)); vec4 snapColor = texture(snapshotTex, snapUV); - // Fully unexplored: show snapshot if it has data, otherwise discard if (losState < 0.05) { if (snapColor.a < 0.1) discard; - fragColor = vec4(snapColor.rgb * 0.35, snapColor.a * 0.7); + fragColor = vec4(snapColor.rgb * 0.3, snapColor.a * 0.6); return; } - // In radar/fog: blend snapshot (dimmed) with fading live geometry float capT = clamp(capacity / 100.0, 0.0, 1.0); - vec3 barkColor = vec3(0.06, 0.04, 0.02); - vec3 innerColor = mix(vec3(0.20, 0.55, 0.15), vec3(0.50, 0.80, 0.20), capT); + float fullLOS = smoothstep(0.3, 0.8, losState); + + // --- Electric discharge using cable-aligned UVs --- + // u = distance along cable (elmos), v = across (-1 to +1) + float along = cableUV.x * 0.06; // scale along to noise frequency + float across = cableUV.y * 0.5 + 0.5; // remap -1..+1 to 0..1 + + float timeShift = gameTime * 2.5 * fullLOS; + + // Main bolt: noise displaces center position across the ribbon + // Subtract time so energy flows root→leaf (direction of increasing u) + float boltOffset = fbm(vec2(along * 4.0 - timeShift, along * 0.7 + 5.0)) - 0.5; + + // Secondary bolt (slightly different frequency) + float bolt2Offset = fbm(vec2(along * 6.0 + timeShift * 0.8, along * 1.3 + 19.0)) - 0.5; + + // Distance from bolt centers + float boltCenter = 0.5 + boltOffset * 0.5; + float bolt2Center = 0.5 + bolt2Offset * 0.4; + float distBolt1 = abs(across - boltCenter); + float distBolt2 = abs(across - bolt2Center); + + // Sharp core (gaussian falloff) + float coreW = 0.05 + capT * 0.05; + float core1 = exp(-distBolt1 * distBolt1 / (coreW * coreW)); + float core2 = exp(-distBolt2 * distBolt2 / (coreW * 1.8 * coreW * 1.8)) * 0.35; + + // Outer glow + float glowW = 0.18 + capT * 0.08; + float glow = exp(-distBolt1 * distBolt1 / (glowW * glowW)) * 0.5; + + float intensity = core1 + core2 + glow; + + // Flicker + float flicker = 0.7 + 0.3 * hash(vec2(floor(gameTime * 10.0), floor(along * 3.0))); + intensity *= mix(1.0, flicker, fullLOS); + + // Branch dimming + if (isBranch > 0.5) intensity *= 0.45; - float n = hash(worldPos.xz * 0.1); - float innerMix = 0.4 + 0.2 * n; - if (isBranch > 0.5) innerMix *= 0.6; + // Color + vec3 coreColor = mix(vec3(0.6, 0.75, 1.0), vec3(1.0, 1.0, 1.0), core1); + vec3 glowColor = mix(vec3(0.08, 0.12, 0.35), vec3(0.3, 0.5, 1.0), capT); + vec3 color = coreColor * (core1 + core2) + glowColor * glow; - vec3 liveColor = mix(barkColor, innerColor, innerMix); + // Edge fade at ribbon borders + float edgeDist = 1.0 - abs(cableUV.y); // 0 at edges, 1 at center + float edgeFade = smoothstep(0.0, 0.2, edgeDist); - // Animated pulse (only full LOS) - float fullLOS = step(0.7, losState); - float pulse = 0.5 + 0.5 * sin(gameTime * 3.0 + worldPos.x * 0.05 + worldPos.z * 0.05); - liveColor += innerColor * pulse * 0.15 * fullLOS * capT; + float dimFactor = mix(0.35, 1.0, fullLOS); + color *= dimFactor; - // Blend: full LOS = live color, radar = dimmed live, low = snapshot - float liveFactor = smoothstep(0.3, 0.8, losState); - float dimFactor = mix(0.4, 1.0, liveFactor); + float alpha = clamp(intensity * edgeFade, 0.0, 1.0) * 0.95; + if (alpha < 0.02) discard; - fragColor = vec4(liveColor * dimFactor, 0.9); + fragColor = vec4(color, alpha); } ]] @@ -1209,6 +1277,7 @@ local function RebuildVBO() vbo:Define(vertCount, { { id = 0, name = "vertPos", size = 3 }, { id = 1, name = "vertData", size = 3 }, + { id = 2, name = "vertUV", size = 2 }, }) vbo:Upload(verts) From c087966780c7b81d0cab1f5f3d3f28742c5d5339 Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 14 Apr 2026 00:41:38 +0200 Subject: [PATCH 09/59] cable test v1 --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 188 ++++++++++++---------- 1 file changed, 104 insertions(+), 84 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index c921556b7b..e42c5cb1d8 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -954,10 +954,8 @@ local function BuildCableVertices() end end - -- Compute left/right vertices + cumulative U distance at each waypoint - local lefts = {} - local rights = {} - local uDist = {} -- cumulative distance along path + -- Cumulative distance along path + local uDist = {} uDist[1] = 0 for i = 2, #pts do local dx = pts[i].x - pts[i-1].x @@ -965,43 +963,77 @@ local function BuildCableVertices() uDist[i] = uDist[i-1] + sqrt(dx*dx + dz*dz) end - for i = 1, #pts do - local hw = (wds[i] or wds[#wds] or 5) * 0.55 - local p = perps[i] - local y = spGetGroundHeight(pts[i].x, pts[i].z) + 2 - lefts[i] = { x = pts[i].x - p.nx * hw, y = y, z = pts[i].z - p.nz * hw } - rights[i] = { x = pts[i].x + p.nx * hw, y = y, z = pts[i].z + p.nz * hw } - end + -- Generate N separate cable strands, each as its own ribbon + -- at different heights and lateral offsets + local numStrands = branch == 1 and 2 or (2 + floor(min(1, cap / MAX_CAPACITY_REF) * 2)) + local strandRadius = max(2, (wds[1] or 5) * 0.25) -- each strand is ~25% of total width + + for strand = 1, numStrands do + local strandSeed = strand * 17.3 + 5.7 + -- Hash for deterministic strand color ID + local colorID = floor(Hash(strandSeed, 0, 0) * 2 + 2) -- 0..3 mapped + + for i = 1, #pts do + local w = wds[i] or wds[#wds] or 5 + local p = perps[i] + local baseY = spGetGroundHeight(pts[i].x, pts[i].z) + + -- Lateral offset: smooth weave across the bundle width + local uScaled = (uDist[i] or 0) * 0.012 + local lateralBase = (strand - (numStrands + 1) * 0.5) * (w * 0.3 / numStrands) + local lateralNoise = (Hash(uScaled * (0.8 + strand * 0.3), strandSeed, strandSeed) * 0.5 + + Hash(uScaled * 0.4 + 3.0, strandSeed + 7, strandSeed) * 0.3) * w * 0.3 + local lateralOffset = lateralBase + lateralNoise + + -- Height offset: cables stack vertically, with noise + local heightBase = (strand - 1) * strandRadius * 1.2 + local heightNoise = Hash(uScaled * 0.6, strandSeed + 13, strandSeed) * strandRadius * 0.8 + local heightOffset = heightBase + heightNoise + 1.5 + + -- Position: center point offset laterally and vertically + local cx = pts[i].x + p.nx * lateralOffset + local cz = pts[i].z + p.nz * lateralOffset + local cy = baseY + heightOffset + + -- Left/right edges of this strand + local sr = strandRadius * 0.55 + pts[i]["sL" .. strand] = { x = cx - p.nx * sr, y = cy, z = cz - p.nz * sr } + pts[i]["sR" .. strand] = { x = cx + p.nx * sr, y = cy, z = cz + p.nz * sr } + end - -- Emit triangles: 8 floats per vert (pos3 + data3 + uv2) - for i = 1, #pts - 1 do - local L1, R1 = lefts[i], rights[i] - local L2, R2 = lefts[i+1], rights[i+1] - local u1, u2 = uDist[i], uDist[i+1] - - -- Tri 1: L1, R1, R2 - verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z - verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 - verts[#verts+1]=u1; verts[#verts+1]=-1 - verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z - verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 - verts[#verts+1]=u1; verts[#verts+1]=1 - verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z - verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 - verts[#verts+1]=u2; verts[#verts+1]=1 - - -- Tri 2: L1, R2, L2 - verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z - verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i] or 5 - verts[#verts+1]=u1; verts[#verts+1]=-1 - verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z - verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 - verts[#verts+1]=u2; verts[#verts+1]=1 - verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z - verts[#verts+1]=cap; verts[#verts+1]=branch; verts[#verts+1]=wds[i+1] or 5 - verts[#verts+1]=u2; verts[#verts+1]=-1 - - vertCount = vertCount + 6 + -- Emit triangles for this strand + for i = 1, #pts - 1 do + local L1 = pts[i]["sL" .. strand] + local R1 = pts[i]["sR" .. strand] + local L2 = pts[i+1]["sL" .. strand] + local R2 = pts[i+1]["sR" .. strand] + local u1, u2 = uDist[i], uDist[i+1] + local br = branch == 1 and 1 or 0 + + -- Tri 1 + verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z + verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 + verts[#verts+1]=u1; verts[#verts+1]=-1 + verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z + verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 + verts[#verts+1]=u1; verts[#verts+1]=1 + verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z + verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 + verts[#verts+1]=u2; verts[#verts+1]=1 + + -- Tri 2 + verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z + verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 + verts[#verts+1]=u1; verts[#verts+1]=-1 + verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z + verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 + verts[#verts+1]=u2; verts[#verts+1]=1 + verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z + verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 + verts[#verts+1]=u2; verts[#verts+1]=-1 + + vertCount = vertCount + 6 + end end end -- if #pts >= 2 @@ -1145,7 +1177,6 @@ void main() { float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); - // Snapshot ghosting vec2 snapUV = clamp(worldPos.xz / mapDims, vec2(0.0), vec2(1.0)); vec4 snapColor = texture(snapshotTex, snapUV); @@ -1158,60 +1189,49 @@ void main() { float capT = clamp(capacity / 100.0, 0.0, 1.0); float fullLOS = smoothstep(0.3, 0.8, losState); - // --- Electric discharge using cable-aligned UVs --- - // u = distance along cable (elmos), v = across (-1 to +1) - float along = cableUV.x * 0.06; // scale along to noise frequency - float across = cableUV.y * 0.5 + 0.5; // remap -1..+1 to 0..1 + // v = -1 to +1 across this individual strand ribbon + float v = cableUV.y; // -1 left edge, +1 right edge + float t = abs(v); // 0 at center, 1 at edge - float timeShift = gameTime * 2.5 * fullLOS; + // Cylinder normal: round cross-section + // The strand ribbon is flat, but we fake a round normal + float ny = sqrt(max(0.0, 1.0 - t * t)); // hemisphere: 1 at center, 0 at edge + float nx = v; // sideways: -1 left, +1 right + vec3 cylNormal = normalize(vec3(nx * 0.5, ny, nx * 0.5)); - // Main bolt: noise displaces center position across the ribbon - // Subtract time so energy flows root→leaf (direction of increasing u) - float boltOffset = fbm(vec2(along * 4.0 - timeShift, along * 0.7 + 5.0)) - 0.5; + // Sun diffuse lighting + float diffuse = max(0.15, dot(cylNormal, normalize(sunDir.xyz))); - // Secondary bolt (slightly different frequency) - float bolt2Offset = fbm(vec2(along * 6.0 + timeShift * 0.8, along * 1.3 + 19.0)) - 0.5; + // Specular (Blinn-Phong) + vec3 viewDir = normalize(cameraViewInv[3].xyz - worldPos); + vec3 halfDir = normalize(normalize(sunDir.xyz) + viewDir); + float spec = pow(max(0.0, dot(cylNormal, halfDir)), 40.0) * 0.45; - // Distance from bolt centers - float boltCenter = 0.5 + boltOffset * 0.5; - float bolt2Center = 0.5 + bolt2Offset * 0.4; - float distBolt1 = abs(across - boltCenter); - float distBolt2 = abs(across - bolt2Center); + // Per-strand color from isBranch field (encodes colorID * 0.1 + branch flag) + float colorID = floor(fract(isBranch) * 10.0 + 0.5); + vec3 baseColor; + if (colorID < 1.0) baseColor = vec3(0.32, 0.20, 0.07); // brown + else if (colorID < 2.0) baseColor = vec3(0.14, 0.28, 0.09); // dark green + else if (colorID < 3.0) baseColor = vec3(0.38, 0.26, 0.11); // copper + else baseColor = vec3(0.22, 0.11, 0.07); // dark red-brown - // Sharp core (gaussian falloff) - float coreW = 0.05 + capT * 0.05; - float core1 = exp(-distBolt1 * distBolt1 / (coreW * coreW)); - float core2 = exp(-distBolt2 * distBolt2 / (coreW * 1.8 * coreW * 1.8)) * 0.35; + baseColor *= (1.0 + capT * 0.3); - // Outer glow - float glowW = 0.18 + capT * 0.08; - float glow = exp(-distBolt1 * distBolt1 / (glowW * glowW)) * 0.5; + // Final lit color + vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.88) * spec; - float intensity = core1 + core2 + glow; + // Subtle surface texture noise + float surfNoise = hash(worldPos.xz * 0.5) * 0.06; + color += vec3(surfNoise * 0.5, surfNoise * 0.3, surfNoise * 0.1); - // Flicker - float flicker = 0.7 + 0.3 * hash(vec2(floor(gameTime * 10.0), floor(along * 3.0))); - intensity *= mix(1.0, flicker, fullLOS); + // Soft edge fade (anti-alias at strand border) + float edgeFade = smoothstep(1.0, 0.8, t); - // Branch dimming - if (isBranch > 0.5) intensity *= 0.45; - - // Color - vec3 coreColor = mix(vec3(0.6, 0.75, 1.0), vec3(1.0, 1.0, 1.0), core1); - vec3 glowColor = mix(vec3(0.08, 0.12, 0.35), vec3(0.3, 0.5, 1.0), capT); - vec3 color = coreColor * (core1 + core2) + glowColor * glow; - - // Edge fade at ribbon borders - float edgeDist = 1.0 - abs(cableUV.y); // 0 at edges, 1 at center - float edgeFade = smoothstep(0.0, 0.2, edgeDist); - - float dimFactor = mix(0.35, 1.0, fullLOS); + // Dim in radar + float dimFactor = mix(0.3, 1.0, fullLOS); color *= dimFactor; - float alpha = clamp(intensity * edgeFade, 0.0, 1.0) * 0.95; - if (alpha < 0.02) discard; - - fragColor = vec4(color, alpha); + fragColor = vec4(color, edgeFade * 0.95); } ]] From a27e45ae4e537a52f4c5226183120ca537a81300 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 24 Apr 2026 22:53:44 +0200 Subject: [PATCH 10/59] opaque tree experiment --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 514 +++++++--------------- LuaRules/gadgets.lua | 3 + LuaUI/cawidgets.lua | 1 + 3 files changed, 175 insertions(+), 343 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index e42c5cb1d8..cea5e8fac2 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -722,9 +722,10 @@ local function normalizeAngle(a) return a end --- Build all cable geometry from renderEdges, return flat vertex array. --- Paths are converted to smooth triangle strips with averaged normals at junctions. -local function BuildCableVertices() +-- Build organic tree geometry from renderEdges. +-- Returns two lists: segments {{x1,z1,x2,z2,width,capacity,isBranch}, ...} +-- and pads {{cx,cz,radius}, ...} for FBO rendering. +local function GenerateOrganicTree() if #renderEdges == 0 then return {}, 0 end -- Each path = { points = { {x,z}, ... }, widths = { w, ... }, capacity, isBranch } @@ -909,9 +910,8 @@ local function BuildCableVertices() for pk, _ in pairs(roots) do routeNode(pk) end - -- Convert paths to smooth triangle strips. - -- At each waypoint, compute perpendicular averaged from incoming+outgoing directions. - -- Each pair of consecutive waypoints forms a quad (2 triangles, 6 verts). + -- Convert paths to triangle strip vertices (smooth ribbons with averaged normals) + -- Format per vertex: x, y, z, capacity, isBranch, width, u, v local verts = {} local vertCount = 0 @@ -923,176 +923,95 @@ local function BuildCableVertices() local branch = path.isBranch if #pts >= 2 then - - -- Compute averaged perpendicular at each waypoint - local perps = {} -- { {nx, nz}, ... } perpendicular directions at each point - for i = 1, #pts do - local px, pz = 0, 0 - if i > 1 then - local dx = pts[i].x - pts[i-1].x - local dz = pts[i].z - pts[i-1].z - local len = sqrt(dx*dx + dz*dz) - if len > 0.01 then - px = px + (-dz/len) - pz = pz + ( dx/len) + -- Averaged perpendicular at each waypoint + local perps = {} + for i = 1, #pts do + local px, pz = 0, 0 + if i > 1 then + local dx = pts[i].x - pts[i-1].x + local dz = pts[i].z - pts[i-1].z + local len = sqrt(dx*dx + dz*dz) + if len > 0.01 then px = px + (-dz/len); pz = pz + (dx/len) end end - end - if i < #pts then - local dx = pts[i+1].x - pts[i].x - local dz = pts[i+1].z - pts[i].z - local len = sqrt(dx*dx + dz*dz) - if len > 0.01 then - px = px + (-dz/len) - pz = pz + ( dx/len) + if i < #pts then + local dx = pts[i+1].x - pts[i].x + local dz = pts[i+1].z - pts[i].z + local len = sqrt(dx*dx + dz*dz) + if len > 0.01 then px = px + (-dz/len); pz = pz + (dx/len) end + end + local plen = sqrt(px*px + pz*pz) + if plen > 0.01 then + perps[i] = { nx = px/plen, nz = pz/plen } + else + perps[i] = { nx = 0, nz = 1 } end end - local plen = sqrt(px*px + pz*pz) - if plen > 0.01 then - perps[i] = { nx = px/plen, nz = pz/plen } - else - perps[i] = { nx = 0, nz = 1 } - end - end - - -- Cumulative distance along path - local uDist = {} - uDist[1] = 0 - for i = 2, #pts do - local dx = pts[i].x - pts[i-1].x - local dz = pts[i].z - pts[i-1].z - uDist[i] = uDist[i-1] + sqrt(dx*dx + dz*dz) - end - -- Generate N separate cable strands, each as its own ribbon - -- at different heights and lateral offsets - local numStrands = branch == 1 and 2 or (2 + floor(min(1, cap / MAX_CAPACITY_REF) * 2)) - local strandRadius = max(2, (wds[1] or 5) * 0.25) -- each strand is ~25% of total width - - for strand = 1, numStrands do - local strandSeed = strand * 17.3 + 5.7 - -- Hash for deterministic strand color ID - local colorID = floor(Hash(strandSeed, 0, 0) * 2 + 2) -- 0..3 mapped + -- Cumulative U distance + local uDist = { [1] = 0 } + for i = 2, #pts do + local dx = pts[i].x - pts[i-1].x + local dz = pts[i].z - pts[i-1].z + uDist[i] = uDist[i-1] + sqrt(dx*dx + dz*dz) + end + -- Left/right vertices at each waypoint + local lefts = {} + local rights = {} for i = 1, #pts do - local w = wds[i] or wds[#wds] or 5 + local hw = (wds[i] or 5) * 0.55 local p = perps[i] - local baseY = spGetGroundHeight(pts[i].x, pts[i].z) - - -- Lateral offset: smooth weave across the bundle width - local uScaled = (uDist[i] or 0) * 0.012 - local lateralBase = (strand - (numStrands + 1) * 0.5) * (w * 0.3 / numStrands) - local lateralNoise = (Hash(uScaled * (0.8 + strand * 0.3), strandSeed, strandSeed) * 0.5 + - Hash(uScaled * 0.4 + 3.0, strandSeed + 7, strandSeed) * 0.3) * w * 0.3 - local lateralOffset = lateralBase + lateralNoise - - -- Height offset: cables stack vertically, with noise - local heightBase = (strand - 1) * strandRadius * 1.2 - local heightNoise = Hash(uScaled * 0.6, strandSeed + 13, strandSeed) * strandRadius * 0.8 - local heightOffset = heightBase + heightNoise + 1.5 - - -- Position: center point offset laterally and vertically - local cx = pts[i].x + p.nx * lateralOffset - local cz = pts[i].z + p.nz * lateralOffset - local cy = baseY + heightOffset - - -- Left/right edges of this strand - local sr = strandRadius * 0.55 - pts[i]["sL" .. strand] = { x = cx - p.nx * sr, y = cy, z = cz - p.nz * sr } - pts[i]["sR" .. strand] = { x = cx + p.nx * sr, y = cy, z = cz + p.nz * sr } + local y = spGetGroundHeight(pts[i].x, pts[i].z) + 2 + lefts[i] = { x = pts[i].x - p.nx * hw, y = y, z = pts[i].z - p.nz * hw } + rights[i] = { x = pts[i].x + p.nx * hw, y = y, z = pts[i].z + p.nz * hw } end - -- Emit triangles for this strand + local brVal = branch == 1 and 1 or 0 + for i = 1, #pts - 1 do - local L1 = pts[i]["sL" .. strand] - local R1 = pts[i]["sR" .. strand] - local L2 = pts[i+1]["sL" .. strand] - local R2 = pts[i+1]["sR" .. strand] + local L1, R1, L2, R2 = lefts[i], rights[i], lefts[i+1], rights[i+1] local u1, u2 = uDist[i], uDist[i+1] - local br = branch == 1 and 1 or 0 + local w1, w2 = wds[i] or 5, wds[i+1] or 5 - -- Tri 1 + -- Tri 1: L1, R1, R2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z - verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 - verts[#verts+1]=u1; verts[#verts+1]=-1 + verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 + verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z - verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 - verts[#verts+1]=u1; verts[#verts+1]=1 + verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 + verts[#verts+1]=u1; verts[#verts+1]=1 verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z - verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 - verts[#verts+1]=u2; verts[#verts+1]=1 + verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 + verts[#verts+1]=u2; verts[#verts+1]=1 - -- Tri 2 + -- Tri 2: L1, R2, L2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z - verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 - verts[#verts+1]=u1; verts[#verts+1]=-1 + verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 + verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z - verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 - verts[#verts+1]=u2; verts[#verts+1]=1 + verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 + verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z - verts[#verts+1]=cap; verts[#verts+1]=br + colorID * 0.1; verts[#verts+1]=strandRadius * 2 - verts[#verts+1]=u2; verts[#verts+1]=-1 + verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 + verts[#verts+1]=u2; verts[#verts+1]=-1 vertCount = vertCount + 6 end end - - end -- if #pts >= 2 end return verts, vertCount end -------------------------------------------------------------------------------------- --- Shader sources -------------------------------------------------------------------------------------- ------------------------------------------------------------------------------------- --- Snapshot shader: renders cables flat (top-down) into the snapshot texture. --- Only updates pixels in LOS. Uses $info to mask. +-- Deferred G-buffer rendering via DrawGroundDeferred. +-- Outputs normals + albedo to MRT targets; engine applies lighting/shadows/fog. ------------------------------------------------------------------------------------- -local snapVSSrc = [[ -#version 420 -#extension GL_ARB_uniform_buffer_object : require -#extension GL_ARB_shading_language_420pack: require - -layout (location = 0) in vec3 vertPos; -layout (location = 1) in vec3 vertData; -layout (location = 2) in vec2 vertUV; - -uniform vec2 mapDims; - -out float vCapacity; -out float vIsBranch; - -void main() { - vCapacity = vertData.x; - vIsBranch = vertData.y; - vec2 ndc = (vertPos.xz / mapDims) * 2.0 - 1.0; - gl_Position = vec4(ndc, 0.0, 1.0); -} -]] - -local snapFSSrc = [[ -#version 330 - -in float vCapacity; -in float vIsBranch; - -out vec4 fragColor; - -void main() { - float capT = clamp(vCapacity / 100.0, 0.0, 1.0); - // Electric blue for snapshot (static, no animation) - vec3 color = mix(vec3(0.15, 0.2, 0.5), vec3(0.4, 0.6, 1.0), capT); - if (vIsBranch > 0.5) color *= 0.5; - fragColor = vec4(color, 1.0); -} -]] - -------------------------------------------------------------------------------------- --- Main 3D shader: draws live cables in LOS, samples snapshot for fog areas -------------------------------------------------------------------------------------- +local cableShader +local cableVAO +local numCableVerts = 0 local cableVSSrc = [[ #version 420 @@ -1100,8 +1019,8 @@ local cableVSSrc = [[ #extension GL_ARB_shading_language_420pack: require layout (location = 0) in vec3 vertPos; -layout (location = 1) in vec3 vertData; -layout (location = 2) in vec2 vertUV; // u = along cable, v = -1(left) to +1(right) +layout (location = 1) in vec3 vertData; // capacity, isBranch, width +layout (location = 2) in vec2 vertUV; // u = along cable, v = -1..+1 across out DataVS { vec3 worldPos; @@ -1129,16 +1048,14 @@ local cableFSSrc = [[ #extension GL_ARB_shading_language_420pack: require uniform sampler2D infoTex; -uniform sampler2D snapshotTex; uniform float gameTime; -uniform vec2 mapDims; in DataVS { vec3 worldPos; float capacity; float isBranch; float width; - vec2 cableUV; // u = distance along cable (elmos), v = -1(left) to +1(right) + vec2 cableUV; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1149,131 +1066,95 @@ float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } -float noise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - f = f * f * (3.0 - 2.0 * f); - float a = hash(i); - float b = hash(i + vec2(1.0, 0.0)); - float c = hash(i + vec2(0.0, 1.0)); - float d = hash(i + vec2(1.0, 1.0)); - return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); -} - -float fbm(vec2 p) { - float val = 0.0; - float amp = 0.5; - for (int i = 0; i < 3; i++) { - val += amp * noise(p); - p *= 2.1; - amp *= 0.5; - } - return val; -} - void main() { - // LOS - vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; - float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); - float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); - - vec2 snapUV = clamp(worldPos.xz / mapDims, vec2(0.0), vec2(1.0)); - vec4 snapColor = texture(snapshotTex, snapUV); + float v = cableUV.y; + float t = abs(v); + // Hard discard at edge — no alpha fade (prevents transparency artifacts) + if (t > 0.90) discard; - if (losState < 0.05) { - if (snapColor.a < 0.1) discard; - fragColor = vec4(snapColor.rgb * 0.3, snapColor.a * 0.6); - return; - } - - float capT = clamp(capacity / 100.0, 0.0, 1.0); - float fullLOS = smoothstep(0.3, 0.8, losState); + // Fake cylinder normal (flat ribbon → round appearance) + float ny = sqrt(max(0.0, 1.0 - t * t)); + float nx = v; + vec3 cylNormal = normalize(vec3(nx * 0.4, ny, nx * 0.4)); - // v = -1 to +1 across this individual strand ribbon - float v = cableUV.y; // -1 left edge, +1 right edge - float t = abs(v); // 0 at center, 1 at edge + // Own lighting (forward rendered, no engine lighting applies) + float diffuse = max(0.25, dot(cylNormal, normalize(sunDir.xyz))); - // Cylinder normal: round cross-section - // The strand ribbon is flat, but we fake a round normal - float ny = sqrt(max(0.0, 1.0 - t * t)); // hemisphere: 1 at center, 0 at edge - float nx = v; // sideways: -1 left, +1 right - vec3 cylNormal = normalize(vec3(nx * 0.5, ny, nx * 0.5)); - - // Sun diffuse lighting - float diffuse = max(0.15, dot(cylNormal, normalize(sunDir.xyz))); - - // Specular (Blinn-Phong) + // Specular vec3 viewDir = normalize(cameraViewInv[3].xyz - worldPos); vec3 halfDir = normalize(normalize(sunDir.xyz) + viewDir); - float spec = pow(max(0.0, dot(cylNormal, halfDir)), 40.0) * 0.45; - - // Per-strand color from isBranch field (encodes colorID * 0.1 + branch flag) - float colorID = floor(fract(isBranch) * 10.0 + 0.5); - vec3 baseColor; - if (colorID < 1.0) baseColor = vec3(0.32, 0.20, 0.07); // brown - else if (colorID < 2.0) baseColor = vec3(0.14, 0.28, 0.09); // dark green - else if (colorID < 3.0) baseColor = vec3(0.38, 0.26, 0.11); // copper - else baseColor = vec3(0.22, 0.11, 0.07); // dark red-brown + float spec = pow(max(0.0, dot(cylNormal, halfDir)), 24.0) * 0.35; - baseColor *= (1.0 + capT * 0.3); + // Capacity-based color (green glow) + float capT = clamp(capacity / 100.0, 0.0, 1.0); + vec3 barkColor = vec3(0.06, 0.04, 0.02); + vec3 innerColor = mix(vec3(0.20, 0.55, 0.15), vec3(0.50, 0.80, 0.20), capT); - // Final lit color - vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.88) * spec; + float innerMix = smoothstep(0.85, 0.15, t); + if (isBranch > 0.5) innerMix *= 0.7; + vec3 baseColor = mix(barkColor, innerColor, innerMix); - // Subtle surface texture noise - float surfNoise = hash(worldPos.xz * 0.5) * 0.06; - color += vec3(surfNoise * 0.5, surfNoise * 0.3, surfNoise * 0.1); + // Surface noise detail + float surfN = hash(worldPos.xz * 0.5) * 0.04; + baseColor += vec3(surfN); - // Soft edge fade (anti-alias at strand border) - float edgeFade = smoothstep(1.0, 0.8, t); + // Apply lighting + vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.85) * spec; - // Dim in radar - float dimFactor = mix(0.3, 1.0, fullLOS); + // LOS-aware dimming + vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; + float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); + float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); + float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); color *= dimFactor; - fragColor = vec4(color, edgeFade * 0.95); + // FULLY OPAQUE output — like lava. No alpha blending. + fragColor = vec4(color, 1.0); } ]] ------------------------------------------------------------------------------------- --- Drawing +-- Receive data from synced ------------------------------------------------------------------------------------- -function gadget:DrawWorldPreUnit() - if not cableShader or numCableVerts == 0 or not cableVAO then return end - - -- Update snapshot texture if needed (must happen in a draw callin) - if needsSnapshotUpdate and snapshotTex and snapshotShader then - gl.RenderToTexture(snapshotTex, function() - gl.Clear(GL.COLOR_BUFFER_BIT, 0, 0, 0, 0) - snapshotShader:Activate() - snapshotShader:SetUniform("mapDims", MAP_WIDTH, MAP_HEIGHT) - gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) - cableVAO:DrawArrays(GL.TRIANGLES, numCableVerts) - snapshotShader:Deactivate() - end) - needsSnapshotUpdate = false - end +local updateCount = 0 +local function OnCableTreeUpdate() + local data = SYNCED.CableTreeData + if not data then return end - -- Draw live 3D cables - cableShader:Activate() - cableShader:SetUniform("gameTime", Spring.GetGameSeconds()) - cableShader:SetUniform("mapDims", MAP_WIDTH, MAP_HEIGHT) + local spec, fullview = spGetSpectatingState() + local myAllyTeam = spGetMyAllyTeamID() + local allyTeamID = data.allyTeamID - gl.Texture(0, "$info") - if snapshotTex then - gl.Texture(1, snapshotTex) + if not (spec or fullview) and allyTeamID ~= myAllyTeam then return end + if lastVersions[allyTeamID] and data.version == lastVersions[allyTeamID] then return end + lastVersions[allyTeamID] = data.version + + updateCount = updateCount + 1 + if updateCount <= 3 then + Spring.Echo("[CableTree][U] OnCableTreeUpdate #" .. updateCount .. " ally=" .. allyTeamID .. " edges=" .. (data.edgeCount or 0)) end - gl.DepthTest(GL.LEQUAL) - gl.DepthMask(false) - gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) - cableVAO:DrawArrays(GL.TRIANGLES, numCableVerts) + local edges = {} + local count = data.edgeCount or 0 + for i = 1, count do + edges[i] = { + px = data.parentXs[i], pz = data.parentZs[i], + cx = data.childXs[i], cz = data.childZs[i], + progress = data.progresses[i], length = data.lengths[i], + capacity = data.capacities[i], + } + end + edgesByAllyTeam[allyTeamID] = edges - cableShader:Deactivate() - gl.Texture(0, false) - gl.Texture(1, false) - gl.DepthTest(false) + renderEdges = {} + for _, teamEdges in pairs(edgesByAllyTeam) do + for j = 1, #teamEdges do + renderEdges[#renderEdges + 1] = teamEdges[j] + end + end + + needsRebuild = true end ------------------------------------------------------------------------------------- @@ -1281,122 +1162,80 @@ end ------------------------------------------------------------------------------------- local function RebuildVBO() - local verts, vertCount = BuildCableVertices() + local verts, vertCount = GenerateOrganicTree() + Spring.Echo("[CableTree][U] RebuildVBO edges=" .. #renderEdges .. " verts=" .. vertCount) if vertCount == 0 then numCableVerts = 0 needsRebuild = false return end - -- Rebuild VBO/VAO cableVAO = nil - local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) if not vbo then return end - vbo:Define(vertCount, { { id = 0, name = "vertPos", size = 3 }, { id = 1, name = "vertData", size = 3 }, { id = 2, name = "vertUV", size = 2 }, }) vbo:Upload(verts) - cableVAO = gl.GetVAO() - if cableVAO then - cableVAO:AttachVertexBuffer(vbo) - end - + if cableVAO then cableVAO:AttachVertexBuffer(vbo) end numCableVerts = vertCount - - needsSnapshotUpdate = true needsRebuild = false end ------------------------------------------------------------------------------------- --- Receive data from synced +-- Drawing via DrawGroundDeferred (G-buffer MRT output) ------------------------------------------------------------------------------------- -local function OnCableTreeUpdate() - local data = SYNCED.CableTreeData - if not data then return end +function gadget:GameFrame(n) + if needsRebuild and n % 6 == 0 then + RebuildVBO() + end +end - local spec, fullview = spGetSpectatingState() - local myAllyTeam = spGetMyAllyTeamID() - local allyTeamID = data.allyTeamID +function gadget:DrawWorldPreUnit() + if not cableVAO or numCableVerts == 0 or not cableShader then return end - if not (spec or fullview) and allyTeamID ~= myAllyTeam then return end - if lastVersions[allyTeamID] and data.version == lastVersions[allyTeamID] then return end - lastVersions[allyTeamID] = data.version + cableShader:Activate() + cableShader:SetUniform("gameTime", Spring.GetGameSeconds()) - local edges = {} - local count = data.edgeCount or 0 - for i = 1, count do - edges[i] = { - px = data.parentXs[i], pz = data.parentZs[i], - cx = data.childXs[i], cz = data.childZs[i], - progress = data.progresses[i], length = data.lengths[i], - capacity = data.capacities[i], - } - end - edgesByAllyTeam[allyTeamID] = edges + gl.Texture(0, "$info") + gl.Culling(false) + gl.DepthTest(GL.LEQUAL) -- don't draw below terrain + gl.DepthMask(true) -- write to depth buffer (units below won't render over us) + gl.Blending(false) -- fully opaque like lava - renderEdges = {} - for _, teamEdges in pairs(edgesByAllyTeam) do - for j = 1, #teamEdges do - renderEdges[#renderEdges + 1] = teamEdges[j] - end - end + cableVAO:DrawArrays(GL.TRIANGLES, numCableVerts) - needsRebuild = true + cableShader:Deactivate() + gl.Texture(0, false) + gl.DepthTest(false) + gl.DepthMask(false) + gl.Culling(GL.BACK) end ------------------------------------------------------------------------------------- -- Lifecycle ------------------------------------------------------------------------------------- -function gadget:GameFrame(n) - if needsRebuild and n % 6 == 0 then - RebuildVBO() - end -end - function gadget:Initialize() - if not gl.CreateShader or not gl.GetVBO or not gl.GetVAO or not gl.RenderToTexture then - Spring.Echo("[CableTree] Missing GL support, disabling") + Spring.Echo("[CableTree][Unsynced] Initialize called") + local deferredEvents = Spring.GetConfigInt("AllowDrawMapDeferredEvents", -1) + local deferredMap = Spring.GetConfigInt("AllowDeferredMapRendering", -1) + Spring.Echo("[CableTree][Unsynced] AllowDrawMapDeferredEvents = " .. tostring(deferredEvents)) + Spring.Echo("[CableTree][Unsynced] AllowDeferredMapRendering = " .. tostring(deferredMap)) + if deferredEvents ~= 1 then + Spring.Echo("[CableTree][Unsynced] Setting AllowDrawMapDeferredEvents=1 (takes effect on next engine restart!)") + Spring.SetConfigInt("AllowDrawMapDeferredEvents", 1) + end + if not gl.CreateShader or not gl.GetVBO or not gl.GetVAO then + Spring.Echo("[CableTree][Unsynced] Missing GL support, disabling") gadgetHandler:RemoveGadget() return end - -- Create snapshot texture (map-sized, reduced resolution) - snapshotW = floor(MAP_WIDTH / SNAPSHOT_SCALE) - snapshotH = floor(MAP_HEIGHT / SNAPSHOT_SCALE) - snapshotTex = gl.CreateTexture(snapshotW, snapshotH, { - fbo = true, - min_filter = GL.LINEAR, - mag_filter = GL.LINEAR, - wrap_s = GL.CLAMP_TO_EDGE, - wrap_t = GL.CLAMP_TO_EDGE, - }) - if not snapshotTex then - Spring.Echo("[CableTree] Failed to create snapshot texture") - end - - -- Compile snapshot shader (flat 2D projection) - snapshotShader = LuaShader({ - vertex = snapVSSrc, - fragment = snapFSSrc, - uniformFloat = { - mapDims = 0, - }, - }, "Cable Snapshot Shader") - - local snapCompiled = snapshotShader:Initialize() - if not snapCompiled then - Spring.Echo("[CableTree] Snapshot shader failed to compile") - snapshotShader = nil - end - - -- Compile main 3D cable shader local engineUniformBufferDefs = LuaShader.GetEngineUniformBufferDefs() local vsSrc = cableVSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) local fsSrc = cableFSSrc:gsub("//__ENGINEUNIFORMBUFFERDEFS__", engineUniformBufferDefs) @@ -1406,34 +1245,23 @@ function gadget:Initialize() fragment = fsSrc, uniformInt = { infoTex = 0, - snapshotTex = 1, }, uniformFloat = { gameTime = 0, - mapDims = 0, }, - }, "Cable Tree Shader") + }, "Cable Forward Shader") - local compiled = cableShader:Initialize() - if not compiled then - Spring.Echo("[CableTree] Shader compilation failed") + if not cableShader:Initialize() then + Spring.Echo("[CableTree][Unsynced] Shader compile failed") gadgetHandler:RemoveGadget() return end - + Spring.Echo("[CableTree][Unsynced] Shader OK, ready for DrawGroundDeferred") gadgetHandler:AddSyncAction("CableTreeUpdate", OnCableTreeUpdate) end function gadget:Shutdown() - if cableShader then - cableShader:Finalize() - end - if snapshotShader then - snapshotShader:Finalize() - end - if snapshotTex then - gl.DeleteTextureFBO(snapshotTex) - end + if cableShader then cableShader:Finalize() end cableVAO = nil gadgetHandler:RemoveSyncAction("CableTreeUpdate") end diff --git a/LuaRules/gadgets.lua b/LuaRules/gadgets.lua index 42c2f29464..09d425bded 100644 --- a/LuaRules/gadgets.lua +++ b/LuaRules/gadgets.lua @@ -2273,6 +2273,9 @@ function gadgetHandler:DrawWorldRefraction() end + + + function gadgetHandler:DrawScreenEffects(vsx, vsy) tracy.ZoneBeginN("G:DrawScreenEffects") for _,g in r_ipairs(self.DrawScreenEffectsList) do diff --git a/LuaUI/cawidgets.lua b/LuaUI/cawidgets.lua index 18768b05ca..6c83f0e5e8 100644 --- a/LuaUI/cawidgets.lua +++ b/LuaUI/cawidgets.lua @@ -1838,6 +1838,7 @@ function widgetHandler:DrawWorldRefraction() end + function widgetHandler:DrawUnitsPostDeferred() tracy.ZoneBeginN("W:DrawUnitsPostDeferred") for _, w in r_ipairs(self.DrawUnitsPostDeferredList) do From 3424bcf1807f7298915dd3355a5db94d60865132 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 24 Apr 2026 23:02:08 +0200 Subject: [PATCH 11/59] normals --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 33 +++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index cea5e8fac2..f7fe09ef42 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -972,28 +972,37 @@ local function GenerateOrganicTree() local L1, R1, L2, R2 = lefts[i], rights[i], lefts[i+1], rights[i+1] local u1, u2 = uDist[i], uDist[i+1] local w1, w2 = wds[i] or 5, wds[i+1] or 5 + -- Perpendicular (cross-section direction) at each waypoint + local p1x, p1z = perps[i].nx, perps[i].nz + local p2x, p2z = perps[i+1].nx, perps[i+1].nz -- Tri 1: L1, R1, R2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=-1 + verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=1 + verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 + verts[#verts+1]=p2x; verts[#verts+1]=p2z -- Tri 2: L1, R2, L2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=-1 + verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 + verts[#verts+1]=p2x; verts[#verts+1]=p2z verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=-1 + verts[#verts+1]=p2x; verts[#verts+1]=p2z vertCount = vertCount + 6 end @@ -1021,6 +1030,7 @@ local cableVSSrc = [[ layout (location = 0) in vec3 vertPos; layout (location = 1) in vec3 vertData; // capacity, isBranch, width layout (location = 2) in vec2 vertUV; // u = along cable, v = -1..+1 across +layout (location = 3) in vec2 vertPerp; // cross-section direction (nx, nz) in XZ plane out DataVS { vec3 worldPos; @@ -1028,6 +1038,7 @@ out DataVS { float isBranch; float width; vec2 cableUV; + vec2 perp; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1038,6 +1049,7 @@ void main() { isBranch = vertData.y; width = vertData.z; cableUV = vertUV; + perp = vertPerp; gl_Position = cameraViewProj * vec4(vertPos, 1.0); } ]] @@ -1056,6 +1068,7 @@ in DataVS { float isBranch; float width; vec2 cableUV; + vec2 perp; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1069,13 +1082,16 @@ float hash(vec2 p) { void main() { float v = cableUV.y; float t = abs(v); - // Hard discard at edge — no alpha fade (prevents transparency artifacts) if (t > 0.90) discard; - // Fake cylinder normal (flat ribbon → round appearance) - float ny = sqrt(max(0.0, 1.0 - t * t)); - float nx = v; - vec3 cylNormal = normalize(vec3(nx * 0.4, ny, nx * 0.4)); + // Proper cylinder cross-section normal. + // perp is the cross-section direction in world XZ (perpendicular to cable tangent). + // At v=0 (cable center), normal points up (+Y). + // At v=±1 (edges), normal points along perp × sign(v). + // Interpolate via cylinder equation: up*sqrt(1-v²) + side*v + vec3 perp3D = normalize(vec3(perp.x, 0.0, perp.y)); + float up = sqrt(max(0.0, 1.0 - v * v)); + vec3 cylNormal = normalize(vec3(0.0, up, 0.0) + perp3D * v); // Own lighting (forward rendered, no engine lighting applies) float diffuse = max(0.25, dot(cylNormal, normalize(sunDir.xyz))); @@ -1174,9 +1190,10 @@ local function RebuildVBO() local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) if not vbo then return end vbo:Define(vertCount, { - { id = 0, name = "vertPos", size = 3 }, - { id = 1, name = "vertData", size = 3 }, - { id = 2, name = "vertUV", size = 2 }, + { id = 0, name = "vertPos", size = 3 }, + { id = 1, name = "vertData", size = 3 }, + { id = 2, name = "vertUV", size = 2 }, + { id = 3, name = "vertPerp", size = 2 }, }) vbo:Upload(verts) cableVAO = gl.GetVAO() From f61e6b8353955725524a9b47201f60f650db0f77 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 24 Apr 2026 23:08:32 +0200 Subject: [PATCH 12/59] pulse test --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 39 ++++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index f7fe09ef42..d335c8f9ef 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -635,8 +635,8 @@ VFS.Include(luaShaderDir .. "instancevbotable.lua") -- Config ------------------------------------------------------------------------------------- -local MIN_TRUNK_WIDTH = 4 -local MAX_TRUNK_WIDTH = 20 +local MIN_TRUNK_WIDTH = 3 +local MAX_TRUNK_WIDTH = 12 local MAX_CAPACITY_REF = 100 local SEG_LENGTH = 10 -- shorter = smoother curves @@ -1114,13 +1114,42 @@ void main() { float surfN = hash(worldPos.xz * 0.5) * 0.04; baseColor += vec3(surfN); + // LOS state (needed first for animation gating) + vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; + float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); + float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); + float fullLOS = smoothstep(0.7, 1.0, losState); + // Apply lighting vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.85) * spec; + // Traveling energy pulses along the cable + // cableUV.x = distance along cable in elmos + float along = cableUV.x; + float pulseSpeed = 180.0; // elmos/second + float pulsePeriod = 500.0; // elmos between pulses (spacing) + float pulseWidth = 35.0; // elmos (pulse extent) + + // Phase offset per cable branch (derived from perp direction so each cable differs) + float phaseOffset = (perp.x * 17.3 + perp.y * 31.7) * 100.0; + + // Shift "along" backwards over time so pulses travel forward (+u direction) + float shifted = along - gameTime * pulseSpeed + phaseOffset; + float pulsePos = mod(shifted, pulsePeriod); + + // Gaussian falloff — bright bright pulse center, fades to edges + float pulseIntensity = exp(-pulsePos * pulsePos / (pulseWidth * pulseWidth)); + + // Second staggered pulse for richer pattern + float shifted2 = along - gameTime * pulseSpeed * 0.7 + phaseOffset * 1.5 + pulsePeriod * 0.4; + float pulsePos2 = mod(shifted2, pulsePeriod); + pulseIntensity += exp(-pulsePos2 * pulsePos2 / (pulseWidth * pulseWidth)) * 0.6; + + // Pulse color: bright white-green core, more intense at cable center (innerMix) + vec3 pulseColor = vec3(0.7, 1.0, 0.6); + color += pulseColor * pulseIntensity * innerMix * fullLOS * 0.9; + // LOS-aware dimming - vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; - float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); - float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); color *= dimFactor; From 60e8744b3ea54d7c71d1878725bb7c2fc04c6ae6 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 24 Apr 2026 23:12:08 +0200 Subject: [PATCH 13/59] sample ground height texture --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 35 +++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index d335c8f9ef..2c389b11e4 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1028,9 +1028,11 @@ local cableVSSrc = [[ #extension GL_ARB_shading_language_420pack: require layout (location = 0) in vec3 vertPos; -layout (location = 1) in vec3 vertData; // capacity, isBranch, width -layout (location = 2) in vec2 vertUV; // u = along cable, v = -1..+1 across -layout (location = 3) in vec2 vertPerp; // cross-section direction (nx, nz) in XZ plane +layout (location = 1) in vec3 vertData; +layout (location = 2) in vec2 vertUV; +layout (location = 3) in vec2 vertPerp; + +uniform sampler2D heightmapTex; out DataVS { vec3 worldPos; @@ -1043,14 +1045,28 @@ out DataVS { //__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; +} + void main() { - worldPos = vertPos; + // Resample current ground height (so cables track terraform in real time) + vec3 pos = vertPos; + pos.y = heightAtWorldPos(vertPos.xz) + 2.0; + + worldPos = pos; capacity = vertData.x; isBranch = vertData.y; width = vertData.z; cableUV = vertUV; perp = vertPerp; - gl_Position = cameraViewProj * vec4(vertPos, 1.0); + gl_Position = cameraViewProj * vec4(pos, 1.0); } ]] @@ -1248,15 +1264,17 @@ function gadget:DrawWorldPreUnit() cableShader:SetUniform("gameTime", Spring.GetGameSeconds()) gl.Texture(0, "$info") + gl.Texture(1, "$heightmap") gl.Culling(false) - gl.DepthTest(GL.LEQUAL) -- don't draw below terrain - gl.DepthMask(true) -- write to depth buffer (units below won't render over us) - gl.Blending(false) -- fully opaque like lava + gl.DepthTest(GL.LEQUAL) + gl.DepthMask(true) + gl.Blending(false) cableVAO:DrawArrays(GL.TRIANGLES, numCableVerts) cableShader:Deactivate() gl.Texture(0, false) + gl.Texture(1, false) gl.DepthTest(false) gl.DepthMask(false) gl.Culling(GL.BACK) @@ -1291,6 +1309,7 @@ function gadget:Initialize() fragment = fsSrc, uniformInt = { infoTex = 0, + heightmapTex = 1, }, uniformFloat = { gameTime = 0, From dc7379d129a1032906111ce5c4d339713c865d33 Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 10:06:05 +0200 Subject: [PATCH 14/59] simplify --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 59 ++++++----------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 2c389b11e4..8b30b6fd97 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -89,6 +89,9 @@ end -- 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, length, progress, withering, gridKey} local edges = {} @@ -97,7 +100,6 @@ local desiredEdges = {} -- Change detection local lastGridNum = {} -- [unitID] = gridNumber -local lastGridMembers = {} -- [gridKey] = { [unitID]=true } — who was in each grid last time local structureChanged = true local treeVersion = 0 @@ -300,6 +302,7 @@ local function SyncWithGrid() end allyNodes[uid] = nil lastGridNum[uid] = nil + allyOfUnit[uid] = nil end -- Check living units for grid changes @@ -467,17 +470,9 @@ local function SendToUnsyncedAll() -- Build per-allyTeam edge lists (so unsynced only sees own team's cables) local perAlly = {} -- [allyTeamID] = { edgeCount, parentXs, ... } - -- Figure out which allyTeam each edge belongs to by checking parentID for key, edge in pairs(edges) do if edge.progress > 0 then - -- Find allyTeam of this edge's parent - local atID - for allyTeamID, allyNodes in pairs(nodes) do - if allyNodes[edge.parentID] or allyNodes[edge.childID] then - atID = allyTeamID - break - end - end + local atID = allyOfUnit[edge.parentID] or allyOfUnit[edge.childID] if atID then if not perAlly[atID] then perAlly[atID] = { @@ -551,6 +546,7 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam) range = pylonDefs[unitDefID], unitDefID = unitDefID, } + allyOfUnit[unitID] = allyTeamID structureChanged = true end @@ -571,6 +567,7 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) -- Remove from old allyTeam, add to new if nodes[oldAlly] then nodes[oldAlly][unitID] = nil end lastGridNum[unitID] = nil + allyOfUnit[unitID] = nil if nodes[newAlly] then local x, _, z = spGetUnitPosition(unitID) nodes[newAlly][unitID] = { @@ -578,6 +575,7 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) range = pylonDefs[unitDefID], unitDefID = unitDefID, } + allyOfUnit[unitID] = newAlly end structureChanged = true end @@ -595,6 +593,7 @@ function gadget:Initialize() range = pylonDefs[unitDefID], unitDefID = unitDefID, } + allyOfUnit[unitID] = allyTeamID end end end @@ -660,16 +659,10 @@ local edgesByAllyTeam = {} local lastVersions = {} local needsRebuild = false -local cableShader -- 3D shader for live cables + snapshot sampling +local cableShader -- forward shader for cable rendering local cableVAO -- live cable geometry local numCableVerts = 0 -local snapshotTex -- persistent 2D texture (top-down map projection) -local snapshotShader -- simple shader to render cables to snapshot -local SNAPSHOT_SCALE = 4 -- snapshot pixels per elmo (4 = quarter resolution) -local snapshotW, snapshotH -local needsSnapshotUpdate = false - ------------------------------------------------------------------------------------- -- Deterministic noise ------------------------------------------------------------------------------------- @@ -1014,14 +1007,12 @@ end ------------------------------------------------------------------------------------- --- Deferred G-buffer rendering via DrawGroundDeferred. --- Outputs normals + albedo to MRT targets; engine applies lighting/shadows/fog. +-- 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). ------------------------------------------------------------------------------------- -local cableShader -local cableVAO -local numCableVerts = 0 - local cableVSSrc = [[ #version 420 #extension GL_ARB_uniform_buffer_object : require @@ -1178,7 +1169,6 @@ void main() { -- Receive data from synced ------------------------------------------------------------------------------------- -local updateCount = 0 local function OnCableTreeUpdate() local data = SYNCED.CableTreeData if not data then return end @@ -1191,11 +1181,6 @@ local function OnCableTreeUpdate() if lastVersions[allyTeamID] and data.version == lastVersions[allyTeamID] then return end lastVersions[allyTeamID] = data.version - updateCount = updateCount + 1 - if updateCount <= 3 then - Spring.Echo("[CableTree][U] OnCableTreeUpdate #" .. updateCount .. " ally=" .. allyTeamID .. " edges=" .. (data.edgeCount or 0)) - end - local edges = {} local count = data.edgeCount or 0 for i = 1, count do @@ -1224,7 +1209,6 @@ end local function RebuildVBO() local verts, vertCount = GenerateOrganicTree() - Spring.Echo("[CableTree][U] RebuildVBO edges=" .. #renderEdges .. " verts=" .. vertCount) if vertCount == 0 then numCableVerts = 0 needsRebuild = false @@ -1248,7 +1232,7 @@ local function RebuildVBO() end ------------------------------------------------------------------------------------- --- Drawing via DrawGroundDeferred (G-buffer MRT output) +-- Drawing via DrawWorldPreUnit (forward, opaque) ------------------------------------------------------------------------------------- function gadget:GameFrame(n) @@ -1285,17 +1269,7 @@ end ------------------------------------------------------------------------------------- function gadget:Initialize() - Spring.Echo("[CableTree][Unsynced] Initialize called") - local deferredEvents = Spring.GetConfigInt("AllowDrawMapDeferredEvents", -1) - local deferredMap = Spring.GetConfigInt("AllowDeferredMapRendering", -1) - Spring.Echo("[CableTree][Unsynced] AllowDrawMapDeferredEvents = " .. tostring(deferredEvents)) - Spring.Echo("[CableTree][Unsynced] AllowDeferredMapRendering = " .. tostring(deferredMap)) - if deferredEvents ~= 1 then - Spring.Echo("[CableTree][Unsynced] Setting AllowDrawMapDeferredEvents=1 (takes effect on next engine restart!)") - Spring.SetConfigInt("AllowDrawMapDeferredEvents", 1) - end if not gl.CreateShader or not gl.GetVBO or not gl.GetVAO then - Spring.Echo("[CableTree][Unsynced] Missing GL support, disabling") gadgetHandler:RemoveGadget() return end @@ -1317,11 +1291,10 @@ function gadget:Initialize() }, "Cable Forward Shader") if not cableShader:Initialize() then - Spring.Echo("[CableTree][Unsynced] Shader compile failed") + Spring.Echo("[CableTree] Shader compile failed") gadgetHandler:RemoveGadget() return end - Spring.Echo("[CableTree][Unsynced] Shader OK, ready for DrawGroundDeferred") gadgetHandler:AddSyncAction("CableTreeUpdate", OnCableTreeUpdate) end From 6d553370057a63570a221b37ad1effd7083ad304 Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 15:42:11 +0200 Subject: [PATCH 15/59] even more simplify --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 461 +++++++++++----------- 1 file changed, 240 insertions(+), 221 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 8b30b6fd97..7bbd35017f 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1,10 +1,8 @@ ------------------------------------------------------------------------------------- ------------------------------------------------------------------------------------- -- Overdrive Cable Tree Visualization --- Maintains a persistent tree that grows organically as pylons are built/destroyed. --- Cables grow from nearest connected node toward new pylons. --- Cables wither when pylons are destroyed; orphans reconnect. --- Per-edge energy flows computed on fully-grown edges only. +-- Synced: maintains topology + per-edge capacity, sends Full/Delta to unsynced. +-- Unsynced: organic-tree geometry, gameframe-based grow/wither animation in shader. ------------------------------------------------------------------------------------- ------------------------------------------------------------------------------------- @@ -28,8 +26,8 @@ if gadgetHandler:IsSyncedCode() then ------------------------------------------------------------------------------------- -- SYNCED -- Reads gridNumber from unit_mex_overdrive as source of truth. --- Periodically computes desired spanning tree edges per grid. --- Diffs against current edges to produce grow/wither animations. +-- Periodically computes desired spanning tree edges per grid and sends +-- Full or Delta updates to unsynced. Visual progress is unsynced-only. ------------------------------------------------------------------------------------- local spGetUnitPosition = Spring.GetUnitPosition @@ -39,23 +37,25 @@ local spGetUnitRulesParam = Spring.GetUnitRulesParam local spGetUnitIsStunned = Spring.GetUnitIsStunned local spValidUnitID = Spring.ValidUnitID +-- 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 min = math.min local floor = math.floor ------------------------------------------------------------------------------------- -- Config ------------------------------------------------------------------------------------- -local SYNC_PERIOD = 30 -- frames between grid sync (~1/s) -local TICK_PERIOD = 3 -local SEND_PERIOD = 6 -local GROWTH_RATE = 250 -- elmos/s -local WITHER_RATE = 400 -local GAME_SPEED = Game.gameSpeed or 30 -local GROWTH_PER_TICK = GROWTH_RATE / GAME_SPEED * TICK_PERIOD -local WITHER_PER_TICK = WITHER_RATE / GAME_SPEED * TICK_PERIOD +local SYNC_PERIOD = 30 -- frames between grid sync (~1/s); also send cadence ------------------------------------------------------------------------------------- -- Unit definitions @@ -92,18 +92,14 @@ 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, length, progress, withering, gridKey} +-- Edges: edges[edgeKey] = {parentID, childID, px, pz, cx, cz} +-- Visual progress (grow/wither) is unsynced-only, gameframe-driven. local edges = {} --- Desired edges: desiredEdges[edgeKey] = true -local desiredEdges = {} - -- Change detection local lastGridNum = {} -- [unitID] = gridNumber -local structureChanged = true - -local treeVersion = 0 -local dirty = false +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) do local allyTeamList = Spring.GetAllyTeamList() @@ -116,12 +112,6 @@ end -- Helpers ------------------------------------------------------------------------------------- -local function Dist(x1, z1, x2, z2) - local dx = x1 - x2 - local dz = z1 - z2 - return sqrt(dx * dx + dz * dz) -end - local function EdgeKey(id1, id2) if id1 < id2 then return id1 .. ":" .. id2 else return id2 .. ":" .. id1 end @@ -155,15 +145,14 @@ local SPATIAL_CELL = 600 -- spatial hash cell size (covers max pylon range pair) local function BuildGridMST(allyTeamID, gridID) local pylons = {} + -- lastGridNum is the authoritative effective-grid map maintained by + -- SyncWithGrid (already accounts for active/inactive state). for unitID, node in pairs(nodes[allyTeamID]) do - if spValidUnitID(unitID) then - local gid = spGetUnitRulesParam(unitID, "gridNumber") or 0 - if gid == gridID then - pylons[#pylons + 1] = { - unitID = unitID, x = node.x, z = node.z, - range = node.range, unitDefID = node.unitDefID, - } - end + if lastGridNum[unitID] == gridID then + pylons[#pylons + 1] = { + unitID = unitID, x = node.x, z = node.z, + range = node.range, unitDefID = node.unitDefID, + } end end @@ -305,9 +294,11 @@ local function SyncWithGrid() allyOfUnit[uid] = nil end - -- Check living units for grid changes + -- Check living units for grid changes. Inactive units (in-build, EMP'd, + -- disarmed, morphing) are treated as gridless so they're excluded from + -- the MST; the active->inactive transition naturally rebuilds the grid. for unitID, _ in pairs(allyNodes) do - local gridID = spGetUnitRulesParam(unitID, "gridNumber") or 0 + local gridID = (IsActiveForGrid(unitID) and (spGetUnitRulesParam(unitID, "gridNumber") or 0)) or 0 local oldGrid = lastGridNum[unitID] or 0 if gridID ~= oldGrid then lastGridNum[unitID] = gridID @@ -321,77 +312,30 @@ local function SyncWithGrid() end end - -- Nothing changed? - local hasChanges = false - for _ in pairs(changedGrids) do hasChanges = true; break end - if not hasChanges then - structureChanged = false - return - end - - -- Rebuild only changed grids + -- Rebuild only changed grids. Diff old vs new MST so unchanged edges + -- are preserved (no spurious add/remove churn). for gk, info in pairs(changedGrids) do - -- Wither old edges for this grid - if desiredByGrid[gk] then - for ek, _ in pairs(desiredByGrid[gk]) do - if edges[ek] and not edges[ek].withering then - edges[ek].withering = true - desiredEdges[ek] = nil - dirty = true - end + local oldDesired = desiredByGrid[gk] or {} + local newDesired = BuildGridMST(info.allyTeamID, info.gridID) + desiredByGrid[gk] = newDesired + + for ek in pairs(oldDesired) do + if not newDesired[ek] and edges[ek] then + edges[ek] = nil + topologyDirty = true end end - -- Compute new MST - local newEdges = BuildGridMST(info.allyTeamID, info.gridID) - desiredByGrid[gk] = newEdges - - -- Create or revive edges - for ek, einfo in pairs(newEdges) do - desiredEdges[ek] = true - if edges[ek] then - if edges[ek].withering then - edges[ek].withering = false - dirty = true - end - else + for ek, einfo in pairs(newDesired) 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, - length = max(1, Dist(einfo.px, einfo.pz, einfo.cx, einfo.cz)), - progress = 0, withering = false, } - dirty = true - end - end - end - - structureChanged = false -end - -------------------------------------------------------------------------------------- --- Edge growth / wither tick -------------------------------------------------------------------------------------- - -local function TickEdges() - local toRemove = {} - - for key, edge in pairs(edges) do - if edge.withering then - edge.progress = edge.progress - WITHER_PER_TICK - if edge.progress <= 0 then - toRemove[#toRemove + 1] = key + topologyDirty = true end - dirty = true - elseif edge.progress < edge.length then - edge.progress = min(edge.length, edge.progress + GROWTH_PER_TICK) - dirty = true end end - - for i = 1, #toRemove do - edges[toRemove[i]] = nil - end end ------------------------------------------------------------------------------------- @@ -405,18 +349,16 @@ local function ComputeFlows() local parentOf = {} -- [childID] = parentID local edgeByChild = {} -- [childID] = edgeKey - -- Collect all participating nodes + -- Collect all participating nodes (visual grow/wither doesn't gate flow) local nodeSet = {} for key, edge in pairs(edges) do - if edge.progress >= edge.length and not edge.withering then - local pid, cid = edge.parentID, edge.childID - if not children[pid] then children[pid] = {} end - children[pid][#children[pid] + 1] = cid - parentOf[cid] = pid - edgeByChild[cid] = key - nodeSet[pid] = true - nodeSet[cid] = true - end + local pid, cid = edge.parentID, edge.childID + if not children[pid] then children[pid] = {} end + children[pid][#children[pid] + 1] = cid + parentOf[cid] = pid + edgeByChild[cid] = key + nodeSet[pid] = true + nodeSet[cid] = true end -- Find roots (nodes with no parent) @@ -461,53 +403,55 @@ local function ComputeFlows() end ------------------------------------------------------------------------------------- --- Send state to unsynced +-- Send state to unsynced. One Full snapshot per ally, only when topology changed. +-- Capacity drift between topology changes is ignored (acceptable: cable colour +-- only updates when the grid actually mutates). ------------------------------------------------------------------------------------- -local function SendToUnsyncedAll() +local function SendAll() local flows = ComputeFlows() - -- Build per-allyTeam edge lists (so unsynced only sees own team's cables) - local perAlly = {} -- [allyTeamID] = { edgeCount, parentXs, ... } - + -- Bin edges by ally, in one pass. + local perAlly = {} -- [ally] = { keys, pxs, pzs, cxs, czs, caps, n } for key, edge in pairs(edges) do - if edge.progress > 0 then - local atID = allyOfUnit[edge.parentID] or allyOfUnit[edge.childID] - if atID then - if not perAlly[atID] then - perAlly[atID] = { - edgeCount = 0, - parentXs = {}, parentZs = {}, - childXs = {}, childZs = {}, - progresses = {}, lengths = {}, capacities = {}, - } - end - local pa = perAlly[atID] - pa.edgeCount = pa.edgeCount + 1 - local n = pa.edgeCount - pa.parentXs[n] = edge.px - pa.parentZs[n] = edge.pz - pa.childXs[n] = edge.cx - pa.childZs[n] = edge.cz - pa.progresses[n] = edge.progress - pa.lengths[n] = edge.length - pa.capacities[n] = flows[key] or 0 + 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 = {}, 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] = flows[key] or 0 end end - -- Send one message per allyTeam - for atID, pa in pairs(perAlly) do - _G.CableTreeData = { - allyTeamID = atID, - version = treeVersion, - edgeCount = pa.edgeCount, - parentXs = pa.parentXs, parentZs = pa.parentZs, - childXs = pa.childXs, childZs = pa.childZs, - progresses = pa.progresses, lengths = pa.lengths, - capacities = pa.capacities, + -- Fire one message per ally that currently has edges. + for ally, pa in pairs(perAlly) do + _G.CableTreeFull = { + allyTeamID = ally, edgeCount = pa.n, + keys = pa.keys, pxs = pa.pxs, pzs = pa.pzs, + cxs = pa.cxs, czs = pa.czs, caps = pa.caps, } - SendToUnsynced("CableTreeUpdate") + SendToUnsynced("CableTreeFull") + alliesWithEdges[ally] = true + end + + -- Allies whose last edge just disappeared get one zero-edge snapshot so + -- unsynced clears them; then we forget them. + for ally in pairs(alliesWithEdges) do + if not perAlly[ally] then + _G.CableTreeFull = { + allyTeamID = ally, edgeCount = 0, + keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, caps = {}, + } + SendToUnsynced("CableTreeFull") + alliesWithEdges[ally] = nil + end end end @@ -516,20 +460,12 @@ end ------------------------------------------------------------------------------------- function gadget:GameFrame(n) - -- Check for gridNumber changes even without unit create/destroy - -- (stun, disable, activate can change grid membership) if n % SYNC_PERIOD == 2 then SyncWithGrid() - end - - if n % TICK_PERIOD == 0 then - TickEdges() - end - - if dirty and (n % SEND_PERIOD == 0) then - treeVersion = treeVersion + 1 - SendToUnsyncedAll() - dirty = false + if topologyDirty then + SendAll() + topologyDirty = false + end end end @@ -547,15 +483,12 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam) unitDefID = unitDefID, } allyOfUnit[unitID] = allyTeamID - structureChanged = true end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) - if not pylonDefs[unitDefID] then return end -- Don't remove from nodes/lastGridNum here. -- SyncWithGrid will detect the dead unit via spValidUnitID, -- mark the affected grid as changed, and clean up. - structureChanged = true end function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) @@ -564,7 +497,6 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) local _, _, _, _, _, oldAlly = Spring.GetTeamInfo(oldTeam, false) if not newAlly or not oldAlly then return end if newAlly ~= oldAlly then - -- Remove from old allyTeam, add to new if nodes[oldAlly] then nodes[oldAlly][unitID] = nil end lastGridNum[unitID] = nil allyOfUnit[unitID] = nil @@ -577,7 +509,6 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) } allyOfUnit[unitID] = newAlly end - structureChanged = true end end @@ -650,13 +581,18 @@ 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 + ------------------------------------------------------------------------------------- -- State ------------------------------------------------------------------------------------- -local renderEdges = {} +-- edgesByAllyTeam[ally][edgeKey] = { px, pz, cx, cz, capacity, appearFrame, witherFrame } local edgesByAllyTeam = {} -local lastVersions = {} +local renderEdges = {} local needsRebuild = false local cableShader -- forward shader for cable rendering @@ -715,16 +651,13 @@ local function normalizeAngle(a) return a end --- Build organic tree geometry from renderEdges. --- Returns two lists: segments {{x1,z1,x2,z2,width,capacity,isBranch}, ...} --- and pads {{cx,cz,radius}, ...} for FBO rendering. +-- Build organic tree geometry from renderEdges (full edges; growth/wither +-- is animated in the fragment shader via appearTime / witherTime). local function GenerateOrganicTree() if #renderEdges == 0 then return {}, 0 end - -- Each path = { points = { {x,z}, ... }, widths = { w, ... }, capacity, isBranch } local allPaths = {} - -- Same tree-building + routing code as before, but collect into allSegs local nodePos = {} local nodeChildren = {} local nodeParent = {} @@ -736,29 +669,23 @@ local function GenerateOrganicTree() for i = 1, #renderEdges do local e = renderEdges[i] - if e.length > 0 then - local frac = min(1, e.progress / e.length) - if frac > 0.01 then - local pk = posKey(e.px, e.pz) - local ex = e.px + frac * (e.cx - e.px) - local ez = e.pz + frac * (e.cz - e.pz) - local ck = posKey(ex, ez) - nodePos[pk] = { x = e.px, z = e.pz } - nodePos[ck] = { x = ex, z = ez } - if not nodeChildren[pk] then nodeChildren[pk] = {} end - nodeChildren[pk][#nodeChildren[pk] + 1] = { - key = ck, cap = max(1, e.capacity), frac = frac, - } - nodeParent[ck] = pk - end - end + local pk = posKey(e.px, e.pz) + local ck = posKey(e.cx, e.cz) + nodePos[pk] = { x = e.px, z = e.pz } + nodePos[ck] = { x = e.cx, z = e.cz } + if not nodeChildren[pk] then nodeChildren[pk] = {} end + nodeChildren[pk][#nodeChildren[pk] + 1] = { + key = ck, cap = max(1, e.capacity), + appearFrame = e.appearFrame, witherFrame = e.witherFrame, + } + nodeParent[ck] = pk end for pk, _ in pairs(nodePos) do if not nodeParent[pk] then roots[pk] = true end end - local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch) + local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, appearFrame, witherFrame) local path = NoisyPath(x1, z1, x2, z2, widthStart * NOISE_AMP, seed) local widths = {} for pi = 1, #path do @@ -768,6 +695,7 @@ local function GenerateOrganicTree() allPaths[#allPaths + 1] = { points = path, widths = widths, capacity = capacity, isBranch = isBranch and 1 or 0, + appearFrame = appearFrame, witherFrame = witherFrame, } -- Twigs: spawn from ribbon edge, not center for pi = 2, #path - 1 do @@ -805,7 +733,8 @@ local function GenerateOrganicTree() end allPaths[#allPaths + 1] = { points = twigPts, widths = twigWidths, - capacity = capacity, isBranch = 1, -- same capacity as parent for color match + capacity = capacity, isBranch = 1, + appearFrame = appearFrame, witherFrame = witherFrame, } end end @@ -855,7 +784,7 @@ local function GenerateOrganicTree() local child = children[1] local cpos = nodePos[child.key] if cpos then - emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, trunkW, GetTrunkWidth(child.cap), totalCap, pos.x + pos.z, false) + emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, trunkW, GetTrunkWidth(child.cap), totalCap, pos.x + pos.z, false, child.appearFrame, child.witherFrame) routeNode(child.key) end return @@ -869,31 +798,44 @@ local function GenerateOrganicTree() local cpos = nodePos[child.key] if cpos then local bw = GetTrunkWidth(child.cap) - emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, min(bw * 1.3, trunkW * 0.7), bw * 0.8, child.cap, pos.x * 3.7 + pos.z * 1.3 + ci, #clusters > 1) + emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, min(bw * 1.3, trunkW * 0.7), bw * 0.8, child.cap, pos.x * 3.7 + pos.z * 1.3 + ci, #clusters > 1, child.appearFrame, child.witherFrame) routeNode(child.key) end else local avgCos, avgSin, clusterCap, minDist = 0, 0, 0, math.huge + -- Stem frames: appear with the earliest child; wither only if all children withering + local stemAppear = math.huge + local stemWither = -math.huge + local allWither = true for i = 1, #cluster do local a = cluster[i].angle or 0 avgCos = avgCos + cos(a) avgSin = avgSin + sin(a) clusterCap = clusterCap + cluster[i].cap if cluster[i].dist and cluster[i].dist < minDist then minDist = cluster[i].dist end + local af = cluster[i].appearFrame or 0 + if af < stemAppear then stemAppear = af end + if cluster[i].witherFrame then + if cluster[i].witherFrame > stemWither then stemWither = cluster[i].witherFrame end + else + allWither = false + end end + if stemAppear == math.huge then stemAppear = 0 end + local stemWitherFinal = (allWither and stemWither > -math.huge) and stemWither or nil local avgAngle = atan2(avgSin, avgCos) local stemLen = min(minDist * STEM_FRACTION, 120) local stemX = pos.x + cos(avgAngle) * stemLen local stemZ = pos.z + sin(avgAngle) * stemLen local stemW = GetTrunkWidth(clusterCap) - emitNoisyPath(pos.x, pos.z, stemX, stemZ, stemW, stemW * 0.9, clusterCap, pos.x + pos.z + ci * 7.3, false) + emitNoisyPath(pos.x, pos.z, stemX, stemZ, stemW, stemW * 0.9, clusterCap, pos.x + pos.z + ci * 7.3, false, stemAppear, stemWitherFinal) table.sort(cluster, function(a, b) return a.cap > b.cap end) for i = 1, #cluster do local child = cluster[i] local cpos = nodePos[child.key] if cpos then local bw = GetTrunkWidth(child.cap) - emitNoisyPath(stemX, stemZ, cpos.x, cpos.z, min(bw * 1.2, stemW * 0.6), bw * 0.7, child.cap, stemX * 2.1 + stemZ * 5.3 + i, i > 1) + emitNoisyPath(stemX, stemZ, cpos.x, cpos.z, min(bw * 1.2, stemW * 0.6), bw * 0.7, child.cap, stemX * 2.1 + stemZ * 5.3 + i, i > 1, child.appearFrame, child.witherFrame) routeNode(child.key) end end @@ -914,6 +856,8 @@ local function GenerateOrganicTree() local wds = path.widths local cap = path.capacity local branch = path.isBranch + local appearTime = (path.appearFrame or 0) / GAME_SPEED + local witherTime = path.witherFrame and (path.witherFrame / GAME_SPEED) or 0 if #pts >= 2 then -- Averaged perpendicular at each waypoint @@ -974,28 +918,34 @@ local function GenerateOrganicTree() verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=p1x; verts[#verts+1]=p1z + verts[#verts+1]=appearTime; verts[#verts+1]=witherTime verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=1 verts[#verts+1]=p1x; verts[#verts+1]=p1z + verts[#verts+1]=appearTime; verts[#verts+1]=witherTime verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=p2x; verts[#verts+1]=p2z + verts[#verts+1]=appearTime; verts[#verts+1]=witherTime -- Tri 2: L1, R2, L2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=p1x; verts[#verts+1]=p1z + verts[#verts+1]=appearTime; verts[#verts+1]=witherTime verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=p2x; verts[#verts+1]=p2z + verts[#verts+1]=appearTime; verts[#verts+1]=witherTime verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=-1 verts[#verts+1]=p2x; verts[#verts+1]=p2z + verts[#verts+1]=appearTime; verts[#verts+1]=witherTime vertCount = vertCount + 6 end @@ -1022,6 +972,7 @@ layout (location = 0) in vec3 vertPos; layout (location = 1) in vec3 vertData; layout (location = 2) in vec2 vertUV; layout (location = 3) in vec2 vertPerp; +layout (location = 4) in vec2 vertTime; // x = appearTime (s), y = witherTime (s, 0 = not withering) uniform sampler2D heightmapTex; @@ -1032,6 +983,7 @@ out DataVS { float width; vec2 cableUV; vec2 perp; + vec2 timeData; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1057,6 +1009,7 @@ void main() { width = vertData.z; cableUV = vertUV; perp = vertPerp; + timeData = vertTime; gl_Position = cameraViewProj * vec4(pos, 1.0); } ]] @@ -1076,10 +1029,14 @@ in DataVS { float width; vec2 cableUV; vec2 perp; + vec2 timeData; // x = appearTime, y = witherTime (0 = not withering) }; //__ENGINEUNIFORMBUFFERDEFS__ +const float GROWTH_RATE = 250.0; // elmos/s — must match unsynced GROWTH_RATE +const float WITHER_RATE = 400.0; + out vec4 fragColor; float hash(vec2 p) { @@ -1091,6 +1048,17 @@ void main() { 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; + } + // Proper cylinder cross-section normal. // perp is the cross-section direction in world XZ (perpendicular to cable tangent). // At v=0 (cable center), normal points up (+Y). @@ -1130,9 +1098,8 @@ void main() { // Apply lighting vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.85) * spec; - // Traveling energy pulses along the cable - // cableUV.x = distance along cable in elmos - float along = cableUV.x; + // Traveling energy pulses along the cable. + // "along" is already in scope from the grow/wither cut above. float pulseSpeed = 180.0; // elmos/second float pulsePeriod = 500.0; // elmos between pulses (spacing) float pulseWidth = 35.0; // elmos (pulse extent) @@ -1169,37 +1136,68 @@ void main() { -- Receive data from synced ------------------------------------------------------------------------------------- -local function OnCableTreeUpdate() - local data = SYNCED.CableTreeData - if not data then return end - +local function shouldAcceptForAlly(allyTeamID) local spec, fullview = spGetSpectatingState() local myAllyTeam = spGetMyAllyTeamID() - local allyTeamID = data.allyTeamID - - if not (spec or fullview) and allyTeamID ~= myAllyTeam then return end - if lastVersions[allyTeamID] and data.version == lastVersions[allyTeamID] then return end - lastVersions[allyTeamID] = data.version - - local edges = {} - local count = data.edgeCount or 0 - for i = 1, count do - edges[i] = { - px = data.parentXs[i], pz = data.parentZs[i], - cx = data.childXs[i], cz = data.childZs[i], - progress = data.progresses[i], length = data.lengths[i], - capacity = data.capacities[i], - } - end - edgesByAllyTeam[allyTeamID] = edges + if (spec or fullview) then return true end + return allyTeamID == myAllyTeam +end +local function RebuildRenderEdges() renderEdges = {} - for _, teamEdges in pairs(edgesByAllyTeam) do - for j = 1, #teamEdges do - renderEdges[#renderEdges + 1] = teamEdges[j] + for _, edges in pairs(edgesByAllyTeam) do + for _, e in pairs(edges) do + renderEdges[#renderEdges + 1] = 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. +local function OnCableTreeFull() + local data = SYNCED.CableTreeFull + if not data then return end + local ally = data.allyTeamID + if not shouldAcceptForAlly(ally) then return end + + local frame = Spring.GetGameFrame() + local existing = edgesByAllyTeam[ally] or {} + + -- Build a fast lookup of incoming keys. + local incoming = {} + for i = 1, data.edgeCount do + incoming[data.keys[i]] = i + end + -- Mark missing edges as withering (or leave them withering if already so). + for k, e in pairs(existing) do + if not incoming[k] and not e.witherFrame then + e.witherFrame = frame + end + end + + -- Add new, refresh capacity on survivors. + for k, i in pairs(incoming) do + local e = existing[k] + if e and not e.witherFrame then + e.capacity = data.caps[i] + -- positions are stable for unchanged edges; assign anyway in case parent moved + e.px, e.pz = data.pxs[i], data.pzs[i] + e.cx, e.cz = data.cxs[i], data.czs[i] + else + existing[k] = { + px = data.pxs[i], pz = data.pzs[i], + cx = data.cxs[i], cz = data.czs[i], + capacity = data.caps[i], + appearFrame = frame, + witherFrame = nil, + } + end + end + + edgesByAllyTeam[ally] = existing + RebuildRenderEdges() needsRebuild = true end @@ -1223,6 +1221,7 @@ local function RebuildVBO() { id = 1, name = "vertData", size = 3 }, { id = 2, name = "vertUV", size = 2 }, { id = 3, name = "vertPerp", size = 2 }, + { id = 4, name = "vertTime", size = 2 }, }) vbo:Upload(verts) cableVAO = gl.GetVAO() @@ -1235,7 +1234,27 @@ 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) + -- Drop fully-withered edges so geometry doesn't grow unboundedly. + 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 + edges[k] = nil + dropped = true + end + end + end + if dropped then + RebuildRenderEdges() + needsRebuild = true + end + if needsRebuild and n % 6 == 0 then RebuildVBO() end @@ -1295,13 +1314,13 @@ function gadget:Initialize() gadgetHandler:RemoveGadget() return end - gadgetHandler:AddSyncAction("CableTreeUpdate", OnCableTreeUpdate) + gadgetHandler:AddSyncAction("CableTreeFull", OnCableTreeFull) end function gadget:Shutdown() if cableShader then cableShader:Finalize() end cableVAO = nil - gadgetHandler:RemoveSyncAction("CableTreeUpdate") + gadgetHandler:RemoveSyncAction("CableTreeFull") end end -- UNSYNCED From 8ecbf36eff923221c1bf00b0f92e4f466616d70c Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 16:47:58 +0200 Subject: [PATCH 16/59] fix re-layout --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 252 +++++++++++++++++----- 1 file changed, 193 insertions(+), 59 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 7bbd35017f..5c547325a0 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -56,6 +56,7 @@ local floor = math.floor ------------------------------------------------------------------------------------- local SYNC_PERIOD = 30 -- frames between grid sync (~1/s); also send cadence +local DEBUG_FLOW = true -- echo per-edge capacity table on every Send ------------------------------------------------------------------------------------- -- Unit definitions @@ -64,6 +65,9 @@ local SYNC_PERIOD = 30 -- frames between grid sync (~1/s); also send cade 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) for i = 1, #UnitDefs do local udef = UnitDefs[i] @@ -80,6 +84,30 @@ for i = 1, #UnitDefs do 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 +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 ------------------------------------------------------------------------------------- @@ -121,19 +149,17 @@ local function GridKey(allyTeamID, gridID) return allyTeamID .. ":" .. gridID end -local function GetNodeCapacity(unitID, unitDefID) - if not spValidUnitID(unitID) then return 0, 0 end - local stunned = spGetUnitIsStunned(unitID) or - (spGetUnitRulesParam(unitID, "disarmed") == 1) - if stunned then return 0, 0 end - local production, consumption = 0, 0 - if generatorDefs[unitDefID] then - production = spGetUnitRulesParam(unitID, "current_energyIncome") or 0 - end - if mexDefs[unitDefID] then - consumption = spGetUnitRulesParam(unitID, "overdrive_energyDrain") or 0 - end - return production, consumption +-- 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 ------------------------------------------------------------------------------------- @@ -200,11 +226,11 @@ local function BuildGridMST(allyTeamID, gridID) end end - -- Root = highest production + -- Root = highest nameplate production (stable across wind/load). local bestRoot = 1 local bestProd = -1 for i = 1, #pylons do - local prod = GetNodeCapacity(pylons[i].unitID, pylons[i].unitDefID) + local prod = GetNodePmax(pylons[i].unitDefID) if prod > bestProd then bestProd = prod; bestRoot = i end end @@ -339,67 +365,175 @@ local function SyncWithGrid() end ------------------------------------------------------------------------------------- --- Flow computation: per-edge capacity via post-order DFS on spanning tree +-- 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. ------------------------------------------------------------------------------------- -local function ComputeFlows() - -- Rebuild tree structure for DFS from edges - local children = {} -- [parentID] = { childID, ... } - local roots = {} -- [unitID] = true - local parentOf = {} -- [childID] = parentID - local edgeByChild = {} -- [childID] = edgeKey - - -- Collect all participating nodes (visual grow/wither doesn't gate flow) +local function ComputeMaxPotentials() + -- Treat edges as undirected: stored parent/child reflects MST traversal + -- order at the time the edge was inserted, NOT actual energy flow. We + -- re-derive both subtree sums and parent/child orientation here, then + -- write the orientation back so downstream rendering animates correctly. + local adj = {} -- [unitID] = { {neigh = id, key = ek}, ... } local nodeSet = {} + local function nodeUnitDefID(uid) + for _, allyNodes in pairs(nodes) do + local n = allyNodes[uid] + if n then return n.unitDefID end + end + return nil + end + for key, edge in pairs(edges) do - local pid, cid = edge.parentID, edge.childID - if not children[pid] then children[pid] = {} end - children[pid][#children[pid] + 1] = cid - parentOf[cid] = pid - edgeByChild[cid] = key - nodeSet[pid] = true - nodeSet[cid] = true + 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 + + -- Per-component DFS rooted at the highest-Pmax node in that component. + -- parentInTree maps child -> { parent, edgeKey } so subtree sums are well-defined. + local parentInTree = {} + local order = {} -- DFS visit order, used for post-order pass + 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 + -- pick best root within this component: highest Pmax + 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 = nodeUnitDefID(v) + local p = did and GetNodePmax(did) or 0 + if p > bestP then bestP = p; bestID = v end + end + dfsRoot(bestID) + end end - -- Find roots (nodes with no parent) - for uid, _ in pairs(nodeSet) do - if not parentOf[uid] then - roots[uid] = true + -- Post-order traversal: subPmax/subDmax of each node's subtree (inclusive). + local subPmax, subDmax = {}, {} + for i = 1, #order do + local u = order[i] + local did = nodeUnitDefID(u) + subPmax[u] = did and GetNodePmax(did) or 0 + subDmax[u] = did and GetNodeDmax(did) or 0 + end + for i = #order, 1, -1 do + local u = order[i] + local pi = parentInTree[u] + if pi then + subPmax[pi.parent] = subPmax[pi.parent] + subPmax[u] + subDmax[pi.parent] = subDmax[pi.parent] + subDmax[u] end end - -- Post-order DFS - local flowResults = {} -- [edgeKey] = capacity - local function dfs(uid) - local prod, cons = 0, 0 - -- Get this node's capacity from any allyTeam - for _, allyNodes in pairs(nodes) do - local node = allyNodes[uid] - if node then - local p, c = GetNodeCapacity(uid, node.unitDefID) - prod = prod + p - cons = cons + c - break + -- Per edge: subtree side = the deeper node (the child in DFS rooting). + -- capAB = max flow subtree -> other; capBA = max flow other -> subtree. + -- Whichever is larger sets parent on the source side. + local capacities = {} + 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 + -- Find root of cid's component (walk up parentInTree). + local r = cid + while parentInTree[r] do r = parentInTree[r].parent end + 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 -- subtree -> other + local capBA = (oP < sD) and oP or sD -- other -> subtree + local cap = (capAB > capBA) and capAB or capBA + capacities[key] = cap + + -- Orient: parent goes on the source side of dominant flow. + local edge = edges[key] + if edge then + local srcSubtree = capAB > capBA + local newParent = srcSubtree and cid or pid + local newChild = srcSubtree and pid or cid + if edge.parentID ~= newParent then + -- Look up positions from `nodes` to get fresh coords. + local function findNode(uid) + for _, allyNodes in pairs(nodes) do + local n = allyNodes[uid] + if n then return n end + end + end + local np, nc = findNode(newParent), findNode(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 children[uid] then - for i = 1, #children[uid] do - local cid = children[uid][i] - local cProd, cCons = dfs(cid) - prod = prod + cProd - cons = cons + cCons - flowResults[edgeByChild[cid]] = max(cProd, cCons) + if debugLog then + local function nameOf(uid) + local d = nodeUnitDefID(uid) + return d and UnitDefs[d].name or tostring(uid) end + local e = edges[key] + debugLog[#debugLog + 1] = string.format(" %-13s -> %-13s sP=%-7.1f sD=%-5s oP=%-7.1f oD=%-5s cap=%.1f", + nameOf(e.parentID), nameOf(e.childID), + sP, fmtD(sD), oP, fmtD(oD), cap) end - return prod, cons end - for uid, _ in pairs(roots) do - dfs(uid) + if debugLog and #debugLog > 0 then + Spring.Echo("[OD-cables] capacities:") + for i = 1, #debugLog do Spring.Echo(debugLog[i]) end end - return flowResults + return capacities end ------------------------------------------------------------------------------------- @@ -409,7 +543,7 @@ end ------------------------------------------------------------------------------------- local function SendAll() - local flows = ComputeFlows() + local flows = ComputeMaxPotentials() -- Bin edges by ally, in one pass. local perAlly = {} -- [ally] = { keys, pxs, pzs, cxs, czs, caps, n } From a993c1b8e7354ba0180c6428c4a454654e10e540 Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 21:35:02 +0200 Subject: [PATCH 17/59] cable fixes --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 251 +++++++++++----------- 1 file changed, 129 insertions(+), 122 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 5c547325a0..f49d4f3498 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -291,75 +291,73 @@ local function BuildGridMST(allyTeamID, gridID) end ------------------------------------------------------------------------------------- --- Grid sync: detect gridNumber changes, rebuild only affected grids +-- 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. ------------------------------------------------------------------------------------- --- Per-grid desired edges -local desiredByGrid = {} -- [gridKey] = { [edgeKey] = info } - local function SyncWithGrid() - -- Detect which grids changed - local changedGrids = {} -- [gridKey] = { allyTeamID, gridID } - + -- Drop dead units. lastGridNum is now only used as a fast change-skip + -- hint (so we don't re-read rules-params if nothing moved); it does NOT + -- gate inclusion in BuildGridMST any more. for allyTeamID, allyNodes in pairs(nodes) do - -- Clean up dead units first - local toRemove = {} + local toRemove for unitID, _ in pairs(allyNodes) do if not spValidUnitID(unitID) then + toRemove = toRemove or {} toRemove[#toRemove + 1] = unitID end end - for i = 1, #toRemove do - local uid = toRemove[i] - local oldGrid = lastGridNum[uid] or 0 - if oldGrid > 0 then - changedGrids[GridKey(allyTeamID, oldGrid)] = { allyTeamID = allyTeamID, gridID = oldGrid } + if toRemove then + for i = 1, #toRemove do + local uid = toRemove[i] + allyNodes[uid] = nil + lastGridNum[uid] = nil + allyOfUnit[uid] = nil end - allyNodes[uid] = nil - lastGridNum[uid] = nil - allyOfUnit[uid] = nil end + end - -- Check living units for grid changes. Inactive units (in-build, EMP'd, - -- disarmed, morphing) are treated as gridless so they're excluded from - -- the MST; the active->inactive transition naturally rebuilds the grid. + -- Refresh lastGridNum from rules-params, group all live pylons by current + -- (allyTeamID, gridID). Inactive pylons (under construction, EMP'd, etc.) + -- map to gridID 0 and are excluded from the MST. + local gridsToBuild = {} -- [gridKey] = { allyTeamID, gridID } + for allyTeamID, allyNodes in pairs(nodes) do for unitID, _ in pairs(allyNodes) do local gridID = (IsActiveForGrid(unitID) and (spGetUnitRulesParam(unitID, "gridNumber") or 0)) or 0 - local oldGrid = lastGridNum[unitID] or 0 - if gridID ~= oldGrid then - lastGridNum[unitID] = gridID - if oldGrid > 0 then - changedGrids[GridKey(allyTeamID, oldGrid)] = { allyTeamID = allyTeamID, gridID = oldGrid } - end - if gridID > 0 then - changedGrids[GridKey(allyTeamID, gridID)] = { allyTeamID = allyTeamID, gridID = gridID } - end + lastGridNum[unitID] = gridID + if gridID > 0 then + gridsToBuild[GridKey(allyTeamID, gridID)] = { allyTeamID = allyTeamID, gridID = gridID } end end end - -- Rebuild only changed grids. Diff old vs new MST so unchanged edges - -- are preserved (no spurious add/remove churn). - for gk, info in pairs(changedGrids) do - local oldDesired = desiredByGrid[gk] or {} - local newDesired = BuildGridMST(info.allyTeamID, info.gridID) - desiredByGrid[gk] = newDesired - - for ek in pairs(oldDesired) do - if not newDesired[ek] and edges[ek] then - edges[ek] = nil - topologyDirty = true - end + -- Build the desired edge set from scratch. + local newEdges = {} + for _, info in pairs(gridsToBuild) do + local mst = BuildGridMST(info.allyTeamID, info.gridID) + for ek, einfo in pairs(mst) do + newEdges[ek] = einfo end + end - for ek, einfo in pairs(newDesired) 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 - end + -- Diff: drop missing, add new. Survivors keep their entry (and their + -- ComputeMaxPotentials reorientation) untouched. + for ek, _ in pairs(edges) do + if not newEdges[ek] then + edges[ek] = nil + topologyDirty = true + 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 end end end @@ -793,9 +791,10 @@ local function GenerateOrganicTree() local allPaths = {} local nodePos = {} - local nodeChildren = {} - local nodeParent = {} - local roots = {} + -- Undirected adjacency: every edge contributes one entry to each endpoint. + -- `side` is 1 for parent end, 2 for child end (used so each edge ends up + -- with one attach point on each side after clustering). + local nodeNeighbors = {} local function posKey(x, z) return floor(x) .. ":" .. floor(z) @@ -807,16 +806,17 @@ local function GenerateOrganicTree() local ck = posKey(e.cx, e.cz) nodePos[pk] = { x = e.px, z = e.pz } nodePos[ck] = { x = e.cx, z = e.cz } - if not nodeChildren[pk] then nodeChildren[pk] = {} end - nodeChildren[pk][#nodeChildren[pk] + 1] = { - key = ck, cap = max(1, e.capacity), + nodeNeighbors[pk] = nodeNeighbors[pk] or {} + nodeNeighbors[ck] = nodeNeighbors[ck] or {} + local cap = max(1, e.capacity) + nodeNeighbors[pk][#nodeNeighbors[pk] + 1] = { + nKey = ck, edgeIdx = i, side = 1, cap = cap, + appearFrame = e.appearFrame, witherFrame = e.witherFrame, + } + nodeNeighbors[ck][#nodeNeighbors[ck] + 1] = { + nKey = pk, edgeIdx = i, side = 2, cap = cap, appearFrame = e.appearFrame, witherFrame = e.witherFrame, } - nodeParent[ck] = pk - end - - for pk, _ in pairs(nodePos) do - if not nodeParent[pk] then roots[pk] = true end end local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, appearFrame, witherFrame) @@ -874,26 +874,20 @@ local function GenerateOrganicTree() end end - local function clusterByDirection(pos, children) - for i = 1, #children do - local cpos = nodePos[children[i].key] - if cpos then - children[i].angle = atan2(cpos.z - pos.z, cpos.x - pos.x) - children[i].dist = sqrt((cpos.x - pos.x)^2 + (cpos.z - pos.z)^2) - end - end - table.sort(children, function(a, b) return (a.angle or 0) < (b.angle or 0) end) - local clusters = {} - local current = { children[1] } - for i = 2, #children do - if abs(normalizeAngle(children[i].angle - children[i-1].angle)) < MERGE_ANGLE then - current[#current + 1] = children[i] + -- Generic angle clustering: groups items whose angles are within MERGE_ANGLE + -- of an immediate neighbour (after sorting). Handles wrap-around. + local function clusterByAngle(items) + if #items == 0 then return {} end + table.sort(items, function(a, b) return (a.angle or 0) < (b.angle or 0) end) + local clusters = { { items[1] } } + for i = 2, #items do + local cur = clusters[#clusters] + if abs(normalizeAngle(items[i].angle - items[i-1].angle)) < MERGE_ANGLE then + cur[#cur + 1] = items[i] else - clusters[#clusters + 1] = current - current = { children[i] } + clusters[#clusters + 1] = { items[i] } end end - clusters[#clusters + 1] = current if #clusters > 1 then local first, last = clusters[1], clusters[#clusters] if abs(normalizeAngle(first[1].angle - last[#last].angle)) < MERGE_ANGLE then @@ -904,53 +898,43 @@ local function GenerateOrganicTree() return clusters end - local function routeNode(pk) - local pos = nodePos[pk] - if not pos then return end - local children = nodeChildren[pk] - if not children or #children == 0 then return end - - local totalCap = 0 - for i = 1, #children do totalCap = totalCap + children[i].cap end - local trunkW = GetTrunkWidth(totalCap) - - if #children == 1 then - local child = children[1] - local cpos = nodePos[child.key] - if cpos then - emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, trunkW, GetTrunkWidth(child.cap), totalCap, pos.x + pos.z, false, child.appearFrame, child.witherFrame) - routeNode(child.key) + -- For each node, cluster all incident half-edges by direction. A cluster + -- of >=2 emits a stem cable from the node along the cluster's average + -- direction; every edge in that cluster gets the stem-end as its attach + -- point on this side. Singletons attach directly at the node. + local edgeAttach = {} -- [edgeIdx][side] = {x, z, hasStem, stemW} + for nk, nbrs in pairs(nodeNeighbors) do + local pos = nodePos[nk] + for i = 1, #nbrs do + local n = nbrs[i] + local npos = nodePos[n.nKey] + if npos then + n.angle = atan2(npos.z - pos.z, npos.x - pos.x) + n.dist = sqrt((npos.x - pos.x)^2 + (npos.z - pos.z)^2) end - return end - - local clusters = clusterByDirection(pos, children) + local clusters = clusterByAngle(nbrs) for ci = 1, #clusters do local cluster = clusters[ci] if #cluster == 1 then - local child = cluster[1] - local cpos = nodePos[child.key] - if cpos then - local bw = GetTrunkWidth(child.cap) - emitNoisyPath(pos.x, pos.z, cpos.x, cpos.z, min(bw * 1.3, trunkW * 0.7), bw * 0.8, child.cap, pos.x * 3.7 + pos.z * 1.3 + ci, #clusters > 1, child.appearFrame, child.witherFrame) - routeNode(child.key) - end + local n = cluster[1] + edgeAttach[n.edgeIdx] = edgeAttach[n.edgeIdx] or {} + edgeAttach[n.edgeIdx][n.side] = { x = pos.x, z = pos.z, hasStem = false } else local avgCos, avgSin, clusterCap, minDist = 0, 0, 0, math.huge - -- Stem frames: appear with the earliest child; wither only if all children withering local stemAppear = math.huge local stemWither = -math.huge local allWither = true for i = 1, #cluster do - local a = cluster[i].angle or 0 - avgCos = avgCos + cos(a) - avgSin = avgSin + sin(a) - clusterCap = clusterCap + cluster[i].cap - if cluster[i].dist and cluster[i].dist < minDist then minDist = cluster[i].dist end - local af = cluster[i].appearFrame or 0 + local n = cluster[i] + avgCos = avgCos + cos(n.angle) + avgSin = avgSin + sin(n.angle) + clusterCap = clusterCap + n.cap + if n.dist and n.dist < minDist then minDist = n.dist end + local af = n.appearFrame or 0 if af < stemAppear then stemAppear = af end - if cluster[i].witherFrame then - if cluster[i].witherFrame > stemWither then stemWither = cluster[i].witherFrame end + if n.witherFrame then + if n.witherFrame > stemWither then stemWither = n.witherFrame end else allWither = false end @@ -959,25 +943,48 @@ local function GenerateOrganicTree() local stemWitherFinal = (allWither and stemWither > -math.huge) and stemWither or nil local avgAngle = atan2(avgSin, avgCos) local stemLen = min(minDist * STEM_FRACTION, 120) + if stemLen < 4 then stemLen = 4 end local stemX = pos.x + cos(avgAngle) * stemLen local stemZ = pos.z + sin(avgAngle) * stemLen local stemW = GetTrunkWidth(clusterCap) - emitNoisyPath(pos.x, pos.z, stemX, stemZ, stemW, stemW * 0.9, clusterCap, pos.x + pos.z + ci * 7.3, false, stemAppear, stemWitherFinal) - table.sort(cluster, function(a, b) return a.cap > b.cap end) + + -- Emit the stem itself. + emitNoisyPath(pos.x, pos.z, stemX, stemZ, + stemW, stemW * 0.9, clusterCap, + pos.x + pos.z + ci * 7.3, + false, stemAppear, stemWitherFinal) + for i = 1, #cluster do - local child = cluster[i] - local cpos = nodePos[child.key] - if cpos then - local bw = GetTrunkWidth(child.cap) - emitNoisyPath(stemX, stemZ, cpos.x, cpos.z, min(bw * 1.2, stemW * 0.6), bw * 0.7, child.cap, stemX * 2.1 + stemZ * 5.3 + i, i > 1, child.appearFrame, child.witherFrame) - routeNode(child.key) - end + local n = cluster[i] + edgeAttach[n.edgeIdx] = edgeAttach[n.edgeIdx] or {} + edgeAttach[n.edgeIdx][n.side] = { + x = stemX, z = stemZ, hasStem = true, stemW = stemW, + } end end end end - for pk, _ in pairs(roots) do routeNode(pk) end + -- Emit each edge once between its two attach points. Width: an end that + -- attaches at a stem is sized to dock cleanly into that stem (a fraction + -- of stem width); an end at a bare node uses the edge's own trunk width. + for i = 1, #renderEdges do + local e = renderEdges[i] + local attach = edgeAttach[i] + if attach and attach[1] and attach[2] then + local cap = max(1, e.capacity) + local edgeW = GetTrunkWidth(cap) + local function endWidth(a) + if a.hasStem then return min(edgeW * 1.2, a.stemW * 0.55) end + return edgeW + end + local startW = endWidth(attach[1]) + local endW = endWidth(attach[2]) + emitNoisyPath(attach[1].x, attach[1].z, attach[2].x, attach[2].z, + startW, endW, cap, attach[1].x + attach[1].z + i * 1.3, + false, e.appearFrame, e.witherFrame) + end + end -- Convert paths to triangle strip vertices (smooth ribbons with averaged normals) -- Format per vertex: x, y, z, capacity, isBranch, width, u, v From 14fdce1370eda5d1d604aa03f7840190739a3c45 Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 21:56:11 +0200 Subject: [PATCH 18/59] euclidian switch --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 46 +++++++++++++++++------ 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index f49d4f3498..1b331676d5 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -55,8 +55,19 @@ local floor = math.floor -- Config ------------------------------------------------------------------------------------- -local SYNC_PERIOD = 30 -- frames between grid sync (~1/s); also send cadence +local SYNC_PERIOD = 30 -- frames between grid sync (~1/s); also send cadence local DEBUG_FLOW = true -- echo per-edge capacity table on every Send +-- 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 = "euclidean" ------------------------------------------------------------------------------------- -- Unit definitions @@ -163,11 +174,18 @@ local function GetNodeDmax(unitDefID) end ------------------------------------------------------------------------------------- --- Per-grid Prim's MST — only runs for grids whose membership changed. --- O(k²) per changed grid where k = grid size (typically 10-50, trivial). +-- Per-grid Euclidean MST — Prim's where every pair within visual reach is a +-- candidate (no per-pylon range filter). Grid membership is whatever +-- unit_mex_overdrive decides; once "these N pylons are one grid", we lay the +-- shortest-total-cable spanning tree over them. This produces co-linear chains +-- and avoids hub-fan artifacts a long-range pylon would otherwise create. +-- The spatial hash still gates candidate pairs to a generous radius so very +-- large grids stay sub-quadratic; cell size is set large enough that any +-- realistic MST edge falls within a 3x3 cell neighbourhood. ------------------------------------------------------------------------------------- -local SPATIAL_CELL = 600 -- spatial hash cell size (covers max pylon range pair) +local SPATIAL_CELL = 2000 -- cell size; 3x3 neighbourhood covers ~4000 elmo pairs +local MST_CANDIDATE_R = 4000 -- hard cap on candidate-pair distance (squared below) local function BuildGridMST(allyTeamID, gridID) local pylons = {} @@ -185,8 +203,8 @@ local function BuildGridMST(allyTeamID, gridID) local result = {} if #pylons < 2 then return result end - -- Build spatial hash for fast neighbor lookup - local cells = {} -- [cellKey] = { idx, idx, ... } + -- Spatial hash bucket pylons by cell. + local cells = {} for i = 1, #pylons do local p = pylons[i] local cx = floor(p.x / SPATIAL_CELL) @@ -196,14 +214,18 @@ local function BuildGridMST(allyTeamID, gridID) cells[ck][#cells[ck] + 1] = i end - -- Precompute neighbor lists (indices within range) - local neighbors = {} -- [idx] = { idx, idx, ... } + -- Neighbour list. In "euclidean" mode every pair within MST_CANDIDATE_R is + -- a candidate (clean visual MST). In "realistic" mode we keep the engine's + -- pylon-range filter (cables only where pylons can actually reach each + -- other) — faithful to physical wiring. + local rSq = MST_CANDIDATE_R * MST_CANDIDATE_R + local euclidean = MST_MODE == "euclidean" + local neighbors = {} for i = 1, #pylons do neighbors[i] = {} local p = pylons[i] local cx = floor(p.x / SPATIAL_CELL) local cz = floor(p.z / SPATIAL_CELL) - -- Check 3x3 neighborhood of cells for dcx = -1, 1 do for dcz = -1, 1 do local ck = (cx + dcx) * 100000 + (cz + dcz) @@ -215,8 +237,10 @@ local function BuildGridMST(allyTeamID, gridID) local o = pylons[j] local dx = p.x - o.x local dz = p.z - o.z - local cr = p.range + o.range - if dx * dx + dz * dz < cr * cr then + local distSq = dx * dx + dz * dz + local cap = euclidean and rSq + or ((p.range + o.range) * (p.range + o.range)) + if distSq < cap then neighbors[i][#neighbors[i] + 1] = j end end From f9ebb208fb6a3547431f85d6bfc3660ca8aa72b5 Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 23:11:58 +0200 Subject: [PATCH 19/59] bubble world --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 357 ++++++++++++++++++---- 1 file changed, 295 insertions(+), 62 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 1b331676d5..0f7a3f730b 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -173,6 +173,25 @@ local function GetNodeDmax(unitDefID) 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 "overdrive_energyDrain" (per-mex E spent +-- on overdrive this tick). Voltage units (turrets etc.) only need the grid to +-- *reach* their voltage — they don't continuously sink energy — so they read +-- as zero current draw. That keeps cables to idle starlights/desolators +-- showing zero flow even though their nameplate Dmax is high. +local function GetNodeDcurrent(unitID, unitDefID) + if mexDefs[unitDefID] then + return spGetUnitRulesParam(unitID, "overdrive_energyDrain") or 0 + end + return 0 +end + ------------------------------------------------------------------------------------- -- Per-grid Euclidean MST — Prim's where every pair within visual reach is a -- candidate (no per-pylon range filter). Grid membership is whatever @@ -478,12 +497,17 @@ local function ComputeMaxPotentials() end -- Post-order traversal: subPmax/subDmax of each node's subtree (inclusive). + -- Same pass also accumulates subPcur/subDcur using current rules-params, + -- so we can derive both nameplate capacity and live flow per edge below. local subPmax, subDmax = {}, {} + local subPcur, subDcur = {}, {} for i = 1, #order do local u = order[i] local did = nodeUnitDefID(u) subPmax[u] = did and GetNodePmax(did) or 0 subDmax[u] = did and GetNodeDmax(did) or 0 + subPcur[u] = did and GetNodePcurrent(u, did) or 0 + subDcur[u] = did and GetNodeDcurrent(u, did) or 0 end for i = #order, 1, -1 do local u = order[i] @@ -491,13 +515,18 @@ local function ComputeMaxPotentials() if pi then subPmax[pi.parent] = subPmax[pi.parent] + subPmax[u] subDmax[pi.parent] = subDmax[pi.parent] + subDmax[u] + subPcur[pi.parent] = subPcur[pi.parent] + subPcur[u] + subDcur[pi.parent] = subDcur[pi.parent] + subDcur[u] end end -- Per edge: subtree side = the deeper node (the child in DFS rooting). -- capAB = max flow subtree -> other; capBA = max flow other -> subtree. -- Whichever is larger sets parent on the source side. + -- `flow` (current) is computed in the same direction the cable is oriented: + -- flow = min(srcPcur, dstDcur) on the chosen flow side. 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 @@ -507,6 +536,10 @@ local function ComputeMaxPotentials() -- Find root of cid's component (walk up parentInTree). local r = cid while parentInTree[r] do r = parentInTree[r].parent end + + -- Max-potential capacity (drives cable thickness). Symmetric: pick the + -- larger of the two cut-flows; the one that wins also names the + -- "potential source" side. local totalP, totalD = subPmax[r], subDmax[r] local sP, sD = subPmax[cid], subDmax[cid] local oP, oD = totalP - sP, totalD - sD @@ -515,14 +548,33 @@ local function ComputeMaxPotentials() local cap = (capAB > capBA) and capAB or capBA capacities[key] = cap - -- Orient: parent goes on the source side of dominant flow. + -- Current flow: same min-cut math against *live* production / draw. + -- We compute both directions; the winner sets both magnitude and + -- direction. This matters when max-potential and current direction + -- disagree — e.g., a stunned fusion has Pmax > 0 but Pcurrent == 0, + -- so a small solar on the other side actually drives flow. + 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 -- subtree -> other + local flowBA = (oPc < sDc) and oPc or sDc -- other -> subtree + local flow, flowSrcSubtree + if flowAB >= flowBA then + flow, flowSrcSubtree = flowAB, true + else + flow, flowSrcSubtree = flowBA, false + end + if flow < 0 then flow = 0 end + flows[key] = flow + + -- Orient: parent goes on the *current* flow source side. When flow is + -- zero the orientation is arbitrary; bubbles sit still anyway, so the + -- direction doesn't matter visually. local edge = edges[key] if edge then - local srcSubtree = capAB > capBA - local newParent = srcSubtree and cid or pid - local newChild = srcSubtree and pid or cid + local newParent = flowSrcSubtree and cid or pid + local newChild = flowSrcSubtree and pid or cid if edge.parentID ~= newParent then - -- Look up positions from `nodes` to get fresh coords. local function findNode(uid) for _, allyNodes in pairs(nodes) do local n = allyNodes[uid] @@ -544,9 +596,9 @@ local function ComputeMaxPotentials() return d and UnitDefs[d].name or tostring(uid) end local e = edges[key] - debugLog[#debugLog + 1] = string.format(" %-13s -> %-13s sP=%-7.1f sD=%-5s oP=%-7.1f oD=%-5s cap=%.1f", + debugLog[#debugLog + 1] = string.format(" %-13s -> %-13s sP=%-7.1f sD=%-5s oP=%-7.1f oD=%-5s cap=%.1f flow=%.1f", nameOf(e.parentID), nameOf(e.childID), - sP, fmtD(sD), oP, fmtD(oD), cap) + sP, fmtD(sD), oP, fmtD(oD), cap, flow) end end @@ -555,7 +607,7 @@ local function ComputeMaxPotentials() for i = 1, #debugLog do Spring.Echo(debugLog[i]) end end - return capacities + return capacities, flows end ------------------------------------------------------------------------------------- @@ -565,16 +617,19 @@ end ------------------------------------------------------------------------------------- local function SendAll() - local flows = ComputeMaxPotentials() + local capacities, flows = ComputeMaxPotentials() -- Bin edges by ally, in one pass. - local perAlly = {} -- [ally] = { keys, pxs, pzs, cxs, czs, caps, n } + 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 = {}, n = 0 } + pa = { + keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, + caps = {}, flows = {}, effs = {}, n = 0, + } perAlly[ally] = pa end pa.n = pa.n + 1 @@ -582,7 +637,15 @@ local function SendAll() 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] = flows[key] or 0 + pa.caps[i] = capacities[key] or 0 + pa.flows[i] = flows[key] or 0 + -- Grid efficiency (E/M ratio) is uniform across a grid; read it from + -- the parent end. Negative means "no grid" (sentinel from + -- unit_mex_overdrive); we forward 0 in that case → magenta in shader. + local eff = spGetUnitRulesParam(edge.parentID, "gridefficiency") + or spGetUnitRulesParam(edge.childID, "gridefficiency") or 0 + if eff < 0 then eff = 0 end + pa.effs[i] = eff end end @@ -591,7 +654,8 @@ local function SendAll() _G.CableTreeFull = { allyTeamID = ally, edgeCount = pa.n, keys = pa.keys, pxs = pa.pxs, pzs = pa.pzs, - cxs = pa.cxs, czs = pa.czs, caps = pa.caps, + cxs = pa.cxs, czs = pa.czs, + caps = pa.caps, flows = pa.flows, effs = pa.effs, } SendToUnsynced("CableTreeFull") alliesWithEdges[ally] = true @@ -603,7 +667,8 @@ local function SendAll() if not perAlly[ally] then _G.CableTreeFull = { allyTeamID = ally, edgeCount = 0, - keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, caps = {}, + keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, + caps = {}, flows = {}, effs = {}, } SendToUnsynced("CableTreeFull") alliesWithEdges[ally] = nil @@ -618,10 +683,12 @@ end function gadget:GameFrame(n) if n % SYNC_PERIOD == 2 then SyncWithGrid() - if topologyDirty then - SendAll() - topologyDirty = false - end + -- Always send: flow magnitudes and grid efficiency colour change every + -- tick, so unsynced needs the periodic refresh even when topology is + -- unchanged. Diff cost on the unsynced side is cheap (key lookup + + -- attribute upload); geometry only re-generates when keys change. + SendAll() + topologyDirty = false end end @@ -726,7 +793,10 @@ local MAX_TRUNK_WIDTH = 12 local MAX_CAPACITY_REF = 100 local SEG_LENGTH = 10 -- shorter = smoother curves -local NOISE_AMP = 0.6 +-- 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 @@ -784,6 +854,10 @@ local function NoisyPath(x1, z1, x2, z2, amplitude, seed) 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 @@ -791,7 +865,7 @@ local function NoisyPath(x1, z1, x2, z2, amplitude, seed) 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) * amplitude * noiseScale + 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 @@ -833,18 +907,20 @@ local function GenerateOrganicTree() nodeNeighbors[pk] = nodeNeighbors[pk] or {} nodeNeighbors[ck] = nodeNeighbors[ck] or {} local cap = max(1, e.capacity) + local flow = e.flow or 0 + local eff = e.eff or 0 nodeNeighbors[pk][#nodeNeighbors[pk] + 1] = { - nKey = ck, edgeIdx = i, side = 1, cap = cap, + nKey = ck, edgeIdx = i, side = 1, cap = cap, flow = flow, eff = eff, appearFrame = e.appearFrame, witherFrame = e.witherFrame, } nodeNeighbors[ck][#nodeNeighbors[ck] + 1] = { - nKey = pk, edgeIdx = i, side = 2, cap = cap, + nKey = pk, edgeIdx = i, side = 2, cap = cap, flow = flow, eff = eff, appearFrame = e.appearFrame, witherFrame = e.witherFrame, } end - local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, appearFrame, witherFrame) - local path = NoisyPath(x1, z1, x2, z2, widthStart * NOISE_AMP, seed) + local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, appearFrame, witherFrame, flow, eff) + local path = NoisyPath(x1, z1, x2, z2, NOISE_AMP_ABS, seed) local widths = {} for pi = 1, #path do local t = (pi - 1) / max(1, #path - 1) @@ -854,8 +930,11 @@ local function GenerateOrganicTree() points = path, widths = widths, capacity = capacity, isBranch = isBranch and 1 or 0, appearFrame = appearFrame, witherFrame = witherFrame, + flow = flow or 0, eff = eff or 0, } - -- Twigs: spawn from ribbon edge, not center + -- Twigs: spawn from ribbon edge, not center. Twigs are decorative; + -- they share the parent's flow/eff so pulse animation is consistent + -- across the visual cluster. for pi = 2, #path - 1 do local p1 = path[pi] local w = widths[pi] @@ -881,7 +960,7 @@ local function GenerateOrganicTree() local bx2 = edgeX + cos(angle) * bLen local bz2 = edgeZ + sin(angle) * bLen local bw = w * BRANCH_WIDTH * (isBranch and 0.6 or 1.0) - local twigPts = NoisyPath(edgeX, edgeZ, bx2, bz2, bw * 0.6, tseed + 10) + local twigPts = NoisyPath(edgeX, edgeZ, bx2, bz2, NOISE_AMP_ABS * 0.7, tseed + 10) local twigWidths = {} -- Start at parent width, taper to thin tip twigWidths[1] = min(bw, w * 0.4) @@ -893,6 +972,7 @@ local function GenerateOrganicTree() points = twigPts, widths = twigWidths, capacity = capacity, isBranch = 1, appearFrame = appearFrame, witherFrame = witherFrame, + flow = flow or 0, eff = eff or 0, } end end @@ -945,7 +1025,16 @@ local function GenerateOrganicTree() edgeAttach[n.edgeIdx] = edgeAttach[n.edgeIdx] or {} edgeAttach[n.edgeIdx][n.side] = { x = pos.x, z = pos.z, hasStem = false } else + -- Aggregate cluster data: average direction, summed cap, weighted + -- mean flow/eff, and *signed* flow (positive when flow leaves the + -- node along this cluster, negative when it enters). The sign + -- decides whether the stem path is emitted node->stemTip + -- (outward) or stemTip->node (inward), so pulses always travel + -- in the actual direction of energy flow. local avgCos, avgSin, clusterCap, minDist = 0, 0, 0, math.huge + local clusterFlow = 0 + local netFlowSigned = 0 + local effSum, capForEff = 0, 0 local stemAppear = math.huge local stemWither = -math.huge local allWither = true @@ -954,6 +1043,12 @@ local function GenerateOrganicTree() avgCos = avgCos + cos(n.angle) avgSin = avgSin + sin(n.angle) clusterCap = clusterCap + n.cap + clusterFlow = clusterFlow + (n.flow or 0) + -- side=1 → this node is the parent (source) → flow leaves + -- side=2 → this node is the child (sink) → flow enters + netFlowSigned = netFlowSigned + ((n.side == 1) and (n.flow or 0) or -(n.flow or 0)) + effSum = effSum + (n.eff or 0) * n.cap + capForEff = capForEff + n.cap if n.dist and n.dist < minDist then minDist = n.dist end local af = n.appearFrame or 0 if af < stemAppear then stemAppear = af end @@ -971,12 +1066,25 @@ local function GenerateOrganicTree() local stemX = pos.x + cos(avgAngle) * stemLen local stemZ = pos.z + sin(avgAngle) * stemLen local stemW = GetTrunkWidth(clusterCap) - - -- Emit the stem itself. - emitNoisyPath(pos.x, pos.z, stemX, stemZ, - stemW, stemW * 0.9, clusterCap, - pos.x + pos.z + ci * 7.3, - false, stemAppear, stemWitherFinal) + local stemEff = capForEff > 0 and (effSum / capForEff) or 0 + + -- Stem is wider at the node side (where the cables merge) and a + -- bit thinner at the tip — emit accordingly so the merged trunk + -- reads visually regardless of flow direction. + local outward = netFlowSigned >= 0 + if outward then + emitNoisyPath(pos.x, pos.z, stemX, stemZ, + stemW, stemW * 0.9, clusterCap, + pos.x + pos.z + ci * 7.3, + false, stemAppear, stemWitherFinal, + clusterFlow, stemEff) + else + emitNoisyPath(stemX, stemZ, pos.x, pos.z, + stemW * 0.9, stemW, clusterCap, + pos.x + pos.z + ci * 7.3, + false, stemAppear, stemWitherFinal, + clusterFlow, stemEff) + end for i = 1, #cluster do local n = cluster[i] @@ -989,9 +1097,10 @@ local function GenerateOrganicTree() end end - -- Emit each edge once between its two attach points. Width: an end that - -- attaches at a stem is sized to dock cleanly into that stem (a fraction - -- of stem width); an end at a bare node uses the edge's own trunk width. + -- Emit each edge once between its two attach points. attach[1] is the + -- parent (source) end and attach[2] is the child (sink) end, so emitting + -- attach[1] -> attach[2] makes pulses travel in the +u direction = actual + -- direction of energy flow. for i = 1, #renderEdges do local e = renderEdges[i] local attach = edgeAttach[i] @@ -1004,9 +1113,16 @@ local function GenerateOrganicTree() end local startW = endWidth(attach[1]) local endW = endWidth(attach[2]) + -- Seed must be stable across VBO rebuilds, otherwise the noise + -- pattern reshuffles every send (~1 Hz) and the eye reads it as + -- the animation "resetting". Use deterministic coords of both + -- endpoints — independent of pairs() iteration order. + local seed = attach[1].x * 0.137 + attach[1].z * 0.781 + + attach[2].x * 0.293 + attach[2].z * 0.461 emitNoisyPath(attach[1].x, attach[1].z, attach[2].x, attach[2].z, - startW, endW, cap, attach[1].x + attach[1].z + i * 1.3, - false, e.appearFrame, e.witherFrame) + startW, endW, cap, seed, + false, e.appearFrame, e.witherFrame, + e.flow or 0, e.eff or 0) end end @@ -1023,6 +1139,8 @@ local function GenerateOrganicTree() local branch = path.isBranch local appearTime = (path.appearFrame or 0) / GAME_SPEED local witherTime = path.witherFrame and (path.witherFrame / GAME_SPEED) or 0 + local pathEff = path.eff or 0 + local pathFlow = path.flow or 0 if #pts >= 2 then -- Averaged perpendicular at each waypoint @@ -1084,16 +1202,19 @@ local function GenerateOrganicTree() verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=1 verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=p2x; verts[#verts+1]=p2z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow -- Tri 2: L1, R2, L2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z @@ -1101,16 +1222,19 @@ local function GenerateOrganicTree() verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=p2x; verts[#verts+1]=p2z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=-1 verts[#verts+1]=p2x; verts[#verts+1]=p2z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow vertCount = vertCount + 6 end @@ -1138,6 +1262,7 @@ layout (location = 1) in vec3 vertData; layout (location = 2) in vec2 vertUV; layout (location = 3) in vec2 vertPerp; layout (location = 4) in vec2 vertTime; // x = appearTime (s), y = witherTime (s, 0 = not withering) +layout (location = 5) in vec2 vertGrid; // x = grid efficiency (E/M ratio), y = current flow (E/s) uniform sampler2D heightmapTex; @@ -1149,6 +1274,7 @@ out DataVS { vec2 cableUV; vec2 perp; vec2 timeData; + vec2 gridData; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1175,6 +1301,7 @@ void main() { cableUV = vertUV; perp = vertPerp; timeData = vertTime; + gridData = vertGrid; gl_Position = cameraViewProj * vec4(pos, 1.0); } ]] @@ -1195,6 +1322,7 @@ in DataVS { vec2 cableUV; vec2 perp; vec2 timeData; // x = appearTime, y = witherTime (0 = not withering) + vec2 gridData; // x = efficiency (E/M ratio), y = current flow (E/s) }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1208,6 +1336,97 @@ 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). +vec2 bubbleLayer(float along, float t, float speed, float spacing, + float radiusMax, float v, float halfWidthE, float layerSeed) { + float along2 = along - t * speed; + 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 (otherwise the bubble + // gets clipped to a near-1D horizontal stripe). + float radiusE = radiusMax * (0.65 + 0.35 * h1); + radiusE = min(radiusE, halfWidthE * 0.95); + if (radiusE < 0.5) return vec2(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.2; + + float dCrossE = v * halfWidthE - yOffsetE; + float r2 = (dAlong * dAlong + dCrossE * dCrossE) / (radiusE * radiusE); + if (r2 >= 1.0) return vec2(0.0); + float r = sqrt(r2); + + float xn = dAlong / radiusE; + float yn = dCrossE / radiusE; + + // Inner glow: faint disc for body luminosity. + float body = (1.0 - smoothstep(0.0, 1.0, r)) * 0.45; + + // Fresnel-like rim brightest near r=0.82, faded both ways. + float rim = smoothstep(0.50, 0.82, r) * (1.0 - smoothstep(0.82, 1.0, r)); + + // Small specular highlight offset toward the light direction. + vec2 hd = vec2(xn + 0.30, yn + 0.40); + float hr = length(hd); + float spec = 1.0 - smoothstep(0.0, 0.30, hr); + spec *= spec; + + return vec2(body + rim, spec); +} + +// 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); +} + void main() { float v = cableUV.y; float t = abs(v); @@ -1263,30 +1482,39 @@ void main() { // Apply lighting vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.85) * spec; - // Traveling energy pulses along the cable. - // "along" is already in scope from the grow/wither cut above. - float pulseSpeed = 180.0; // elmos/second - float pulsePeriod = 500.0; // elmos between pulses (spacing) - float pulseWidth = 35.0; // elmos (pulse extent) - - // Phase offset per cable branch (derived from perp direction so each cable differs) - float phaseOffset = (perp.x * 17.3 + perp.y * 31.7) * 100.0; - - // Shift "along" backwards over time so pulses travel forward (+u direction) - float shifted = along - gameTime * pulseSpeed + phaseOffset; - float pulsePos = mod(shifted, pulsePeriod); - - // Gaussian falloff — bright bright pulse center, fades to edges - float pulseIntensity = exp(-pulsePos * pulsePos / (pulseWidth * pulseWidth)); - - // Second staggered pulse for richer pattern - float shifted2 = along - gameTime * pulseSpeed * 0.7 + phaseOffset * 1.5 + pulsePeriod * 0.4; - float pulsePos2 = mod(shifted2, pulsePeriod); - pulseIntensity += exp(-pulsePos2 * pulsePos2 / (pulseWidth * pulseWidth)) * 0.6; - - // Pulse color: bright white-green core, more intense at cable center (innerMix) - vec3 pulseColor = vec3(0.7, 1.0, 0.6); - color += pulseColor * pulseIntensity * innerMix * fullLOS * 0.9; + // 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. + // - Three layered streams of bubbles (big, medium, 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. + const float FLOW_REF = 80.0; + float flow = gridData.y; + float flowNorm = clamp(flow / FLOW_REF, 0.0, 1.6); + float bubbleSpeed = 220.0 * flowNorm; // exact zero at zero flow + float halfWidthE = width * 0.5; // cable cross half-extent in elmos + + // Two layers of mixed-size bubbles. Density is fixed (constant spacing); + // per-bubble radius jitter inside each layer gives the small/big mix + // the user wants. Sizes are in elmos so the bubbles are world-round. + vec2 bA = bubbleLayer(along, gameTime, bubbleSpeed, 75.0, 7.5, v, halfWidthE, 3.7); + vec2 bB = bubbleLayer(along, gameTime, bubbleSpeed, 32.0, 4.0, v, halfWidthE, 19.1); + + float bubbleBody = bA.x + bB.x * 0.85; + float bubbleSpec = bA.y + bB.y * 0.85; + + // Bubble colour = grid efficiency colour; whiten the highlight for glow. + vec3 gridColor = gridEfficiencyColor(gridData.x); + vec3 bubbleColor = mix(gridColor, vec3(1.0), 0.25); + color += bubbleColor * bubbleBody * fullLOS * 1.4; + color += vec3(1.0) * bubbleSpec * fullLOS * 0.8; // LOS-aware dimming float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); @@ -1342,11 +1570,13 @@ local function OnCableTreeFull() end end - -- Add new, refresh capacity on survivors. + -- Add new, refresh capacity / flow / efficiency on survivors. for k, i in pairs(incoming) do local e = existing[k] if e and not e.witherFrame then e.capacity = data.caps[i] + e.flow = data.flows and data.flows[i] or 0 + e.eff = data.effs and data.effs[i] or 0 -- positions are stable for unchanged edges; assign anyway in case parent moved e.px, e.pz = data.pxs[i], data.pzs[i] e.cx, e.cz = data.cxs[i], data.czs[i] @@ -1355,6 +1585,8 @@ local function OnCableTreeFull() px = data.pxs[i], pz = data.pzs[i], cx = data.cxs[i], cz = data.czs[i], capacity = data.caps[i], + flow = data.flows and data.flows[i] or 0, + eff = data.effs and data.effs[i] or 0, appearFrame = frame, witherFrame = nil, } @@ -1387,6 +1619,7 @@ local function RebuildVBO() { id = 2, name = "vertUV", size = 2 }, { id = 3, name = "vertPerp", size = 2 }, { id = 4, name = "vertTime", size = 2 }, + { id = 5, name = "vertGrid", size = 2 }, -- (efficiency, flow magnitude) }) vbo:Upload(verts) cableVAO = gl.GetVAO() From 68f9d4174fe95cf7add320529571921902ec6ba1 Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 23:26:59 +0200 Subject: [PATCH 20/59] flow to hub --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 184 +++++++++++++++++----- 1 file changed, 142 insertions(+), 42 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 0f7a3f730b..1ace7e505d 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -36,6 +36,7 @@ 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 @@ -180,16 +181,23 @@ local function GetNodePcurrent(unitID, unitDefID) return spGetUnitRulesParam(unitID, "current_energyIncome") or 0 end --- Current real draw: mexes consume "overdrive_energyDrain" (per-mex E spent --- on overdrive this tick). Voltage units (turrets etc.) only need the grid to --- *reach* their voltage — they don't continuously sink energy — so they read --- as zero current draw. That keeps cables to idle starlights/desolators --- showing zero flow even though their nameplate Dmax is high. +-- 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 - return 0 + local _, _, _, eUse = spGetUnitResources(unitID) + return eUse or 0 end ------------------------------------------------------------------------------------- @@ -547,6 +555,7 @@ local function ComputeMaxPotentials() local capBA = (oP < sD) and oP or sD -- other -> subtree local cap = (capAB > capBA) and capAB or capBA capacities[key] = cap + local potentialSrcSubtree = capAB > capBA -- fallback orientation when flow == 0 -- Current flow: same min-cut math against *live* production / draw. -- We compute both directions; the winner sets both magnitude and @@ -565,11 +574,19 @@ local function ComputeMaxPotentials() flow, flowSrcSubtree = flowBA, false end if flow < 0 then flow = 0 end + -- At zero current flow the cable still has a meaningful "intended" + -- direction: the side that would draw if it could. For an idle turret + -- (voltage unit, Pcurrent=0, Dcurrent=0 but Dmax>0), that's the + -- turret side. Use the max-potential orientation as fallback so the + -- cable visually points AT the consumer; bubbles are motionless + -- anyway because flow == 0. + if flow <= 0 then + flowSrcSubtree = potentialSrcSubtree + end flows[key] = flow - -- Orient: parent goes on the *current* flow source side. When flow is - -- zero the orientation is arbitrary; bubbles sit still anyway, so the - -- direction doesn't matter visually. + -- Orient: parent = source side (current flow when nonzero, max- + -- potential otherwise — handled by the fallback above). local edge = edges[key] if edge then local newParent = flowSrcSubtree and cid or pid @@ -812,6 +829,19 @@ 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. +local BUBBLE_FLOW_REF = 80 +local BUBBLE_MAX_SPEED = 220 +local function flowToSpeed(flow) + if not flow or flow < 0 then return 0 end + local n = flow / BUBBLE_FLOW_REF + if n > 1.6 then n = 1.6 end + return BUBBLE_MAX_SPEED * n +end + ------------------------------------------------------------------------------------- -- State ------------------------------------------------------------------------------------- @@ -824,6 +854,12 @@ local needsRebuild = false local cableShader -- forward shader for cable rendering local cableVAO -- live cable geometry local numCableVerts = 0 +-- 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 @@ -909,17 +945,20 @@ local function GenerateOrganicTree() local cap = max(1, e.capacity) local flow = e.flow or 0 local eff = e.eff or 0 + local bubblePhase = e.bubblePhase or 0 nodeNeighbors[pk][#nodeNeighbors[pk] + 1] = { nKey = ck, edgeIdx = i, side = 1, cap = cap, flow = flow, eff = eff, + bubblePhase = bubblePhase, appearFrame = e.appearFrame, witherFrame = e.witherFrame, } nodeNeighbors[ck][#nodeNeighbors[ck] + 1] = { nKey = pk, edgeIdx = i, side = 2, cap = cap, flow = flow, eff = eff, + bubblePhase = bubblePhase, appearFrame = e.appearFrame, witherFrame = e.witherFrame, } end - local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, appearFrame, witherFrame, flow, eff) + local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, appearFrame, witherFrame, flow, eff, bubblePhase) local path = NoisyPath(x1, z1, x2, z2, NOISE_AMP_ABS, seed) local widths = {} for pi = 1, #path do @@ -931,6 +970,7 @@ local function GenerateOrganicTree() capacity = capacity, isBranch = isBranch and 1 or 0, appearFrame = appearFrame, witherFrame = witherFrame, flow = flow or 0, eff = eff or 0, + bubblePhase = bubblePhase or 0, } -- Twigs: spawn from ribbon edge, not center. Twigs are decorative; -- they share the parent's flow/eff so pulse animation is consistent @@ -973,6 +1013,7 @@ local function GenerateOrganicTree() capacity = capacity, isBranch = 1, appearFrame = appearFrame, witherFrame = witherFrame, flow = flow or 0, eff = eff or 0, + bubblePhase = bubblePhase or 0, } end end @@ -1035,6 +1076,7 @@ local function GenerateOrganicTree() local clusterFlow = 0 local netFlowSigned = 0 local effSum, capForEff = 0, 0 + local phaseSum = 0 local stemAppear = math.huge local stemWither = -math.huge local allWither = true @@ -1048,6 +1090,7 @@ local function GenerateOrganicTree() -- side=2 → this node is the child (sink) → flow enters netFlowSigned = netFlowSigned + ((n.side == 1) and (n.flow or 0) or -(n.flow or 0)) effSum = effSum + (n.eff or 0) * n.cap + phaseSum = phaseSum + (n.bubblePhase or 0) * n.cap capForEff = capForEff + n.cap if n.dist and n.dist < minDist then minDist = n.dist end local af = n.appearFrame or 0 @@ -1067,6 +1110,7 @@ local function GenerateOrganicTree() local stemZ = pos.z + sin(avgAngle) * stemLen local stemW = GetTrunkWidth(clusterCap) local stemEff = capForEff > 0 and (effSum / capForEff) or 0 + local stemPhase = capForEff > 0 and (phaseSum / capForEff) or 0 -- Stem is wider at the node side (where the cables merge) and a -- bit thinner at the tip — emit accordingly so the merged trunk @@ -1077,13 +1121,13 @@ local function GenerateOrganicTree() stemW, stemW * 0.9, clusterCap, pos.x + pos.z + ci * 7.3, false, stemAppear, stemWitherFinal, - clusterFlow, stemEff) + clusterFlow, stemEff, stemPhase) else emitNoisyPath(stemX, stemZ, pos.x, pos.z, stemW * 0.9, stemW, clusterCap, pos.x + pos.z + ci * 7.3, false, stemAppear, stemWitherFinal, - clusterFlow, stemEff) + clusterFlow, stemEff, stemPhase) end for i = 1, #cluster do @@ -1122,7 +1166,7 @@ local function GenerateOrganicTree() emitNoisyPath(attach[1].x, attach[1].z, attach[2].x, attach[2].z, startW, endW, cap, seed, false, e.appearFrame, e.witherFrame, - e.flow or 0, e.eff or 0) + e.flow or 0, e.eff or 0, e.bubblePhase or 0) end end @@ -1139,8 +1183,9 @@ local function GenerateOrganicTree() local branch = path.isBranch local appearTime = (path.appearFrame or 0) / GAME_SPEED local witherTime = path.witherFrame and (path.witherFrame / GAME_SPEED) or 0 - local pathEff = path.eff or 0 - local pathFlow = path.flow or 0 + local pathEff = path.eff or 0 + local pathFlow = path.flow or 0 + local pathPhase = path.bubblePhase or 0 if #pts >= 2 then -- Averaged perpendicular at each waypoint @@ -1202,19 +1247,19 @@ local function GenerateOrganicTree() verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 verts[#verts+1]=u1; verts[#verts+1]=1 verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=p2x; verts[#verts+1]=p2z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase -- Tri 2: L1, R2, L2 verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z @@ -1222,19 +1267,19 @@ local function GenerateOrganicTree() verts[#verts+1]=u1; verts[#verts+1]=-1 verts[#verts+1]=p1x; verts[#verts+1]=p1z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=1 verts[#verts+1]=p2x; verts[#verts+1]=p2z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 verts[#verts+1]=u2; verts[#verts+1]=-1 verts[#verts+1]=p2x; verts[#verts+1]=p2z verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow + verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase vertCount = vertCount + 6 end @@ -1262,7 +1307,7 @@ layout (location = 1) in vec3 vertData; layout (location = 2) in vec2 vertUV; layout (location = 3) in vec2 vertPerp; layout (location = 4) in vec2 vertTime; // x = appearTime (s), y = witherTime (s, 0 = not withering) -layout (location = 5) in vec2 vertGrid; // x = grid efficiency (E/M ratio), y = current flow (E/s) +layout (location = 5) in vec3 vertGrid; // x = grid efficiency (E/M ratio), y = current flow (E/s), z = bubble phase at bake (elmos) uniform sampler2D heightmapTex; @@ -1274,7 +1319,7 @@ out DataVS { vec2 cableUV; vec2 perp; vec2 timeData; - vec2 gridData; + vec3 gridData; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1313,6 +1358,7 @@ local cableFSSrc = [[ uniform sampler2D infoTex; uniform float gameTime; +uniform float bakeTime; in DataVS { vec3 worldPos; @@ -1322,7 +1368,7 @@ in DataVS { vec2 cableUV; vec2 perp; vec2 timeData; // x = appearTime, y = witherTime (0 = not withering) - vec2 gridData; // x = efficiency (E/M ratio), y = current flow (E/s) + vec3 gridData; // x = efficiency (E/M), y = flow (E/s), z = bubble phase at bake (elmos) }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1355,9 +1401,13 @@ float hash1(float n) { // Shading: faint inner glow + Fresnel rim + small offset highlight, all with // smoothstep edges to avoid pixelation at oblique camera angles. Returns // (body, specular). -vec2 bubbleLayer(float along, float t, float speed, float spacing, +vec2 bubbleLayer(float along, float phase, float spacing, float radiusMax, float v, float halfWidthE, float layerSeed) { - float along2 = along - t * speed; + // `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 it from `along` advects + // the bubble field smoothly even when speed steps between frames. + 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); @@ -1386,16 +1436,25 @@ vec2 bubbleLayer(float along, float t, float speed, float spacing, float xn = dAlong / radiusE; float yn = dCrossE / radiusE; - // Inner glow: faint disc for body luminosity. - float body = (1.0 - smoothstep(0.0, 1.0, r)) * 0.45; + // Anti-alias bubble edge using screen-space derivative. Without this, + // thick cables (where one bubble covers many pixels) showed staircase + // pixelation on the rim. fwidth(r) ≈ how much r changes per pixel; we + // soften every smoothstep edge by that amount so transitions span ~1 + // pixel regardless of camera distance. + float aa = clamp(fwidth(r) * 1.4, 0.005, 0.20); - // Fresnel-like rim brightest near r=0.82, faded both ways. - float rim = smoothstep(0.50, 0.82, r) * (1.0 - smoothstep(0.82, 1.0, r)); + // Inner glow: faint disc for body luminosity, with AA outer cutoff. + float body = (1.0 - smoothstep(0.0, 1.0, r)) + * (1.0 - smoothstep(1.0 - aa, 1.0, r)) * 0.50; + + // Fresnel-like rim brightest near r=0.82, faded both ways with AA edges. + float rim = smoothstep(0.50 - aa, 0.82, r) + * (1.0 - smoothstep(0.82, 1.0 - aa * 0.5, r)); // Small specular highlight offset toward the light direction. vec2 hd = vec2(xn + 0.30, yn + 0.40); float hr = length(hd); - float spec = 1.0 - smoothstep(0.0, 0.30, hr); + float spec = 1.0 - smoothstep(0.0, 0.30 + aa, hr); spec *= spec; return vec2(body + rim, spec); @@ -1495,17 +1554,25 @@ void main() { // - Three layered streams of bubbles (big, medium, 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. - const float FLOW_REF = 80.0; + // Bubble speed mapping must match the CPU's flowToSpeed (otherwise the + // CPU-integrated phase and shader-extrapolated phase disagree and we get + // the very jumps this anchor scheme exists to eliminate). + const float FLOW_REF = 80.0; + const float MAX_SPEED = 220.0; float flow = gridData.y; - float flowNorm = clamp(flow / FLOW_REF, 0.0, 1.6); - float bubbleSpeed = 220.0 * flowNorm; // exact zero at zero flow - float halfWidthE = width * 0.5; // cable cross half-extent in elmos + float speed = MAX_SPEED * clamp(flow / FLOW_REF, 0.0, 1.6); + + // 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); + float halfWidthE = width * 0.5; // cable cross half-extent in elmos // Two layers of mixed-size bubbles. Density is fixed (constant spacing); // per-bubble radius jitter inside each layer gives the small/big mix // the user wants. Sizes are in elmos so the bubbles are world-round. - vec2 bA = bubbleLayer(along, gameTime, bubbleSpeed, 75.0, 7.5, v, halfWidthE, 3.7); - vec2 bB = bubbleLayer(along, gameTime, bubbleSpeed, 32.0, 4.0, v, halfWidthE, 19.1); + vec2 bA = bubbleLayer(along, phase, 75.0, 7.5, v, halfWidthE, 3.7); + vec2 bB = bubbleLayer(along, phase, 32.0, 4.0, v, halfWidthE, 19.1); float bubbleBody = bA.x + bB.x * 0.85; float bubbleSpec = bA.y + bB.y * 0.85; @@ -1571,12 +1638,26 @@ local function OnCableTreeFull() 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 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) + e.capacity = data.caps[i] - e.flow = data.flows and data.flows[i] or 0 - e.eff = data.effs and data.effs[i] or 0 + e.flow = newFlow + e.eff = data.effs and data.effs[i] or 0 -- positions are stable for unchanged edges; assign anyway in case parent moved e.px, e.pz = data.pxs[i], data.pzs[i] e.cx, e.cz = data.cxs[i], data.czs[i] @@ -1585,10 +1666,15 @@ local function OnCableTreeFull() px = data.pxs[i], pz = data.pzs[i], cx = data.cxs[i], cz = data.czs[i], capacity = data.caps[i], - flow = data.flows and data.flows[i] or 0, - eff = data.effs and data.effs[i] or 0, + flow = newFlow, + eff = data.effs and data.effs[i] or 0, appearFrame = frame, witherFrame = nil, + -- 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), } end end @@ -1603,6 +1689,18 @@ end ------------------------------------------------------------------------------------- local function RebuildVBO() + -- 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 verts, vertCount = GenerateOrganicTree() if vertCount == 0 then numCableVerts = 0 @@ -1619,7 +1717,7 @@ local function RebuildVBO() { id = 2, name = "vertUV", size = 2 }, { id = 3, name = "vertPerp", size = 2 }, { id = 4, name = "vertTime", size = 2 }, - { id = 5, name = "vertGrid", size = 2 }, -- (efficiency, flow magnitude) + { id = 5, name = "vertGrid", size = 3 }, -- (efficiency, flow E/s, bubble phase elmos) }) vbo:Upload(verts) cableVAO = gl.GetVAO() @@ -1663,6 +1761,7 @@ function gadget:DrawWorldPreUnit() cableShader:Activate() cableShader:SetUniform("gameTime", Spring.GetGameSeconds()) + cableShader:SetUniform("bakeTime", bubbleBakeTime) gl.Texture(0, "$info") gl.Texture(1, "$heightmap") @@ -1704,6 +1803,7 @@ function gadget:Initialize() }, uniformFloat = { gameTime = 0, + bakeTime = 0, }, }, "Cable Forward Shader") From 6562202cf2990b7ed80fe639b231429ab37e8a8c Mon Sep 17 00:00:00 2001 From: Licho Date: Tue, 28 Apr 2026 23:47:02 +0200 Subject: [PATCH 21/59] f --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 130 ++++++++++++++-------- 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 1ace7e505d..81cf1978e3 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1401,12 +1401,15 @@ float hash1(float n) { // Shading: faint inner glow + Fresnel rim + small offset highlight, all with // smoothstep edges to avoid pixelation at oblique camera angles. Returns // (body, specular). -vec2 bubbleLayer(float along, float phase, float spacing, +// `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) { - // `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 it from `along` advects - // the bubble field smoothly even when speed steps between frames. float along2 = along - phase; float idxLow = floor(along2 / spacing); float coord = along2 - idxLow * spacing; // [0, spacing) @@ -1416,48 +1419,54 @@ vec2 bubbleLayer(float along, float phase, float spacing, 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 (otherwise the bubble - // gets clipped to a near-1D horizontal stripe). - float radiusE = radiusMax * (0.65 + 0.35 * h1); - radiusE = min(radiusE, halfWidthE * 0.95); - if (radiusE < 0.5) return vec2(0.0); + // 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.2; + float yOffsetE = (h2 - 0.5) * crossMargin * 1.0; float dCrossE = v * halfWidthE - yOffsetE; - float r2 = (dAlong * dAlong + dCrossE * dCrossE) / (radiusE * radiusE); - if (r2 >= 1.0) return vec2(0.0); - float r = sqrt(r2); + // 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; - // Anti-alias bubble edge using screen-space derivative. Without this, - // thick cables (where one bubble covers many pixels) showed staircase - // pixelation on the rim. fwidth(r) ≈ how much r changes per pixel; we - // soften every smoothstep edge by that amount so transitions span ~1 - // pixel regardless of camera distance. + // 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); - // Inner glow: faint disc for body luminosity, with AA outer cutoff. - float body = (1.0 - smoothstep(0.0, 1.0, r)) - * (1.0 - smoothstep(1.0 - aa, 1.0, r)) * 0.50; + // 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); - // Fresnel-like rim brightest near r=0.82, faded both ways with AA edges. - float rim = smoothstep(0.50 - aa, 0.82, r) - * (1.0 - smoothstep(0.82, 1.0 - aa * 0.5, 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; - // Small specular highlight offset toward the light direction. - vec2 hd = vec2(xn + 0.30, yn + 0.40); + // 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.30 + aa, hr); - spec *= spec; + float spec = 1.0 - smoothstep(0.0, 0.22 + aa, hr); + spec *= spec * spec; // cubed → very sharp - return vec2(body + rim, spec); + // 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 @@ -1502,14 +1511,39 @@ void main() { if (along < witherFront) discard; } - // Proper cylinder cross-section normal. - // perp is the cross-section direction in world XZ (perpendicular to cable tangent). - // At v=0 (cable center), normal points up (+Y). - // At v=±1 (edges), normal points along perp × sign(v). - // Interpolate via cylinder equation: up*sqrt(1-v²) + side*v + // Cylinder cross-section normal that respects cable slope. + // + // `perp` is the *horizontal* cross-section direction baked at the + // vertex. The cable's true tangent in world space (which can have a Y + // component when the cable climbs/descends) is reconstructed from + // screen-space derivatives of `cableUV.x` (= along) versus worldPos — + // this works because `along` is monotone along the cable and screen + // derivatives sample along the surface. With both vectors known, the + // real "up" direction relative to the cable is cross(tangent, perp); + // this rotates with the slope, so an uphill cable shades brightest on + // its actual top side instead of where a horizontal cable would. vec3 perp3D = normalize(vec3(perp.x, 0.0, perp.y)); + + vec3 dWdx = dFdx(worldPos); + vec3 dWdy = dFdy(worldPos); + float duDx = dFdx(cableUV.x); + float duDy = dFdy(cableUV.x); + float denom = duDx * duDx + duDy * duDy; + vec3 cableT; + if (denom > 1e-6) { + cableT = normalize((dWdx * duDx + dWdy * duDy) / denom); + } else { + // Fallback if derivatives are degenerate (single-pixel cable, etc.): + // horizontal tangent perpendicular to perp. + cableT = normalize(vec3(perp.y, 0.0, -perp.x)); + } + + 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(vec3(0.0, up, 0.0) + perp3D * v); + vec3 cylNormal = normalize(trueUp * up + perp3D * v); // Own lighting (forward rendered, no engine lighting applies) float diffuse = max(0.25, dot(cylNormal, normalize(sunDir.xyz))); @@ -1569,19 +1603,27 @@ void main() { float halfWidthE = width * 0.5; // cable cross half-extent in elmos // Two layers of mixed-size bubbles. Density is fixed (constant spacing); - // per-bubble radius jitter inside each layer gives the small/big mix - // the user wants. Sizes are in elmos so the bubbles are world-round. - vec2 bA = bubbleLayer(along, phase, 75.0, 7.5, v, halfWidthE, 3.7); - vec2 bB = bubbleLayer(along, phase, 32.0, 4.0, v, halfWidthE, 19.1); + // per-bubble radius jitter inside each layer gives the small/big mix. + // Each layer returns (body, spec, halo); we composite them with + // different colour weights so the bubble reads as glowing plasma. + vec3 bA = bubbleLayer(along, phase, 75.0, 7.5, v, halfWidthE, 3.7); + vec3 bB = bubbleLayer(along, phase, 32.0, 4.0, v, halfWidthE, 19.1); float bubbleBody = bA.x + bB.x * 0.85; float bubbleSpec = bA.y + bB.y * 0.85; + float bubbleHalo = bA.z + bB.z * 0.55; - // Bubble colour = grid efficiency colour; whiten the highlight for glow. + // Bubble colour: keep the grid efficiency hue (low dilution → punchier). vec3 gridColor = gridEfficiencyColor(gridData.x); - vec3 bubbleColor = mix(gridColor, vec3(1.0), 0.25); - color += bubbleColor * bubbleBody * fullLOS * 1.4; - color += vec3(1.0) * bubbleSpec * fullLOS * 0.8; + vec3 bubbleColor = mix(gridColor, vec3(1.0), 0.15); + vec3 haloColor = gridColor; // pure grid-colour halo + + // Halo first (soft underglow), then body (hot core/rim), then a pure- + // white specular pop on top. Multipliers are tuned for "energy" feel — + // the halo gives bloom, the core gives plasma, the spec gives sparkle. + color += haloColor * bubbleHalo * fullLOS * 0.70; + color += bubbleColor * bubbleBody * fullLOS * 2.0; + color += vec3(1.0) * bubbleSpec * fullLOS * 1.2; // LOS-aware dimming float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); From 576ad31f3c4ba7fd7e999baee44236deca753238 Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 00:14:05 +0200 Subject: [PATCH 22/59] perf counters --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 90 ++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 81cf1978e3..03c2646eec 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -56,8 +56,8 @@ local floor = math.floor -- Config ------------------------------------------------------------------------------------- -local SYNC_PERIOD = 30 -- frames between grid sync (~1/s); also send cadence -local DEBUG_FLOW = true -- echo per-edge capacity table on every Send +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 @@ -148,6 +148,10 @@ do end end +-- Runtime toggles, driven by the /cabletree chat command (see CableTreeCmd). +local cableEnabled = true +local cablePerf = false + ------------------------------------------------------------------------------------- -- Helpers ------------------------------------------------------------------------------------- @@ -697,7 +701,25 @@ end -- GameFrame ------------------------------------------------------------------------------------- +-- Sends one zero-edge snapshot per ally that currently has cables, so the +-- unsynced side clears its geometry. Used when the visualization is toggled +-- off so no stale cables linger. +local function ClearAll() + for ally in pairs(alliesWithEdges) do + _G.CableTreeFull = { + allyTeamID = ally, edgeCount = 0, + keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, + caps = {}, flows = {}, effs = {}, + } + SendToUnsynced("CableTreeFull") + end + alliesWithEdges = {} + edges = {} + topologyDirty = false +end + function gadget:GameFrame(n) + if not cableEnabled then return end if n % SYNC_PERIOD == 2 then SyncWithGrid() -- Always send: flow magnitudes and grid efficiency colour change every @@ -706,7 +728,50 @@ function gadget:GameFrame(n) -- attribute upload); geometry only re-generates when keys change. SendAll() topologyDirty = false + -- Synced has no timing API (Spring.GetTimer is unsynced-only, and the + -- sandbox doesn't expose `os`). Just report the edge count from here; + -- the real cost numbers come from the unsynced rebuild log, which + -- captures the heavier path (geometry + VBO upload). + if cablePerf then + local nEdges = 0 + for _ in pairs(edges) do nEdges = nEdges + 1 end + Spring.Echo(string.format("[CableTree] sync edges=%d", nEdges)) + end + end +end + +-- /cabletree — toggle on/off +-- /cabletree on / off — explicit +-- /cabletree perf — toggle per-cycle timing log +-- /cabletree status — print current state +local function CableTreeCmd(cmd, line, words, playerID) + local arg = (words and words[1]) or "" + if arg == "" or arg == "toggle" then + cableEnabled = not cableEnabled + if not cableEnabled then ClearAll() end + Spring.Echo("[CableTree] " .. (cableEnabled and "ON" or "OFF")) + elseif arg == "on" then + cableEnabled = true + Spring.Echo("[CableTree] ON") + elseif arg == "off" then + cableEnabled = false + ClearAll() + Spring.Echo("[CableTree] OFF") + elseif arg == "perf" then + cablePerf = not cablePerf + _G.CableTreePerf = { perf = cablePerf } + SendToUnsynced("CableTreePerf") + 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 + Spring.Echo(string.format( + "[CableTree] enabled=%s perf=%s edges=%d", + tostring(cableEnabled), tostring(cablePerf), nEdges)) + else + Spring.Echo("[CableTree] usage: /cabletree [on|off|toggle|perf|status]") end + return true end ------------------------------------------------------------------------------------- @@ -754,6 +819,7 @@ end function gadget:Initialize() GG.CableTree = { nodes = nodes, edges = edges } + gadgetHandler:AddChatAction("cabletree", CableTreeCmd) for _, unitID in ipairs(Spring.GetAllUnits()) do local unitDefID = spGetUnitDefID(unitID) if unitDefID and pylonDefs[unitDefID] then @@ -854,6 +920,7 @@ local needsRebuild = false local cableShader -- forward shader for cable rendering local cableVAO -- live cable geometry local numCableVerts = 0 +local drawPerf = false -- toggled by the synced /cabletree perf command -- 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 @@ -1731,6 +1798,8 @@ end ------------------------------------------------------------------------------------- local function RebuildVBO() + local tStart = drawPerf 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() @@ -1743,6 +1812,7 @@ local function RebuildVBO() end end + local tGen0 = drawPerf and Spring.GetTimer() or nil local verts, vertCount = GenerateOrganicTree() if vertCount == 0 then numCableVerts = 0 @@ -1761,11 +1831,22 @@ local function RebuildVBO() { id = 4, name = "vertTime", size = 2 }, { id = 5, name = "vertGrid", size = 3 }, -- (efficiency, flow E/s, bubble phase elmos) }) + local tUp0 = drawPerf and Spring.GetTimer() or nil vbo:Upload(verts) cableVAO = gl.GetVAO() if cableVAO then cableVAO:AttachVertexBuffer(vbo) end numCableVerts = vertCount needsRebuild = false + + if drawPerf then + local tEnd = Spring.GetTimer() + Spring.Echo(string.format( + "[CableTree] draw rebuild: phase=%.2f ms geom=%.2f ms upload=%.2f ms verts=%d", + Spring.DiffTimers(tGen0, tStart) * 1000, + Spring.DiffTimers(tUp0, tGen0) * 1000, + Spring.DiffTimers(tEnd, tUp0) * 1000, + vertCount)) + end end ------------------------------------------------------------------------------------- @@ -1855,12 +1936,17 @@ function gadget:Initialize() return end gadgetHandler:AddSyncAction("CableTreeFull", OnCableTreeFull) + gadgetHandler:AddSyncAction("CableTreePerf", function() + local data = SYNCED.CableTreePerf + if data then drawPerf = data.perf and true or false end + end) end function gadget:Shutdown() if cableShader then cableShader:Finalize() end cableVAO = nil gadgetHandler:RemoveSyncAction("CableTreeFull") + gadgetHandler:RemoveSyncAction("CableTreePerf") end end -- UNSYNCED From d05e82b074805ee7fa991baac4eb9bc9c1089f14 Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 12:10:28 +0200 Subject: [PATCH 23/59] cached geom --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 582 +++++++++++++++------- 1 file changed, 401 insertions(+), 181 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 03c2646eec..1fe47c4fff 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -141,6 +141,29 @@ 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 + +-- 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 @@ -165,6 +188,14 @@ local function GridKey(allyTeamID, gridID) return allyTeamID .. ":" .. gridID end +local function MarkGridDirty(ally, gridID) + if not gridID or gridID <= 0 or not ally then return end + local gk = GridKey(ally, gridID) + if not pendingGridDirty[gk] then + pendingGridDirty[gk] = { ally = ally, gridID = gridID } + end +end + -- Stable nameplate production: solar/fusion/sing fixed; windgen = current WindMax. local function GetNodePmax(unitDefID) if isWindgenByDef[unitDefID] then return GetWindMax() end @@ -354,9 +385,8 @@ end ------------------------------------------------------------------------------------- local function SyncWithGrid() - -- Drop dead units. lastGridNum is now only used as a fast change-skip - -- hint (so we don't re-read rules-params if nothing moved); it does NOT - -- gate inclusion in BuildGridMST any more. + -- 1) Drop dead units; mark their last-known grid dirty so its MST is + -- rebuilt without them. for allyTeamID, allyNodes in pairs(nodes) do local toRemove for unitID, _ in pairs(allyNodes) do @@ -368,42 +398,60 @@ local function SyncWithGrid() if toRemove then for i = 1, #toRemove do local uid = toRemove[i] + MarkGridDirty(allyTeamID, lastGridNum[uid]) allyNodes[uid] = nil lastGridNum[uid] = nil allyOfUnit[uid] = nil + nodeDefByUID[uid] = nil end end end - -- Refresh lastGridNum from rules-params, group all live pylons by current - -- (allyTeamID, gridID). Inactive pylons (under construction, EMP'd, etc.) - -- map to gridID 0 and are excluded from the MST. - local gridsToBuild = {} -- [gridKey] = { allyTeamID, gridID } + -- 2) Refresh lastGridNum from rules-params and detect membership changes. + -- Any pylon whose effective gridID flipped marks BOTH the old and new + -- grid dirty (the old one because it lost a member, the new one + -- because it gained one). Inactive pylons map to 0 and drop out. for allyTeamID, allyNodes in pairs(nodes) do for unitID, _ in pairs(allyNodes) do - local gridID = (IsActiveForGrid(unitID) and (spGetUnitRulesParam(unitID, "gridNumber") or 0)) or 0 - lastGridNum[unitID] = gridID - if gridID > 0 then - gridsToBuild[GridKey(allyTeamID, gridID)] = { allyTeamID = allyTeamID, gridID = gridID } + local newG = (IsActiveForGrid(unitID) and (spGetUnitRulesParam(unitID, "gridNumber") or 0)) or 0 + local oldG = lastGridNum[unitID] + if oldG ~= newG then + MarkGridDirty(allyTeamID, oldG) + MarkGridDirty(allyTeamID, newG) + lastGridNum[unitID] = newG end end end - -- Build the desired edge set from scratch. + -- 3) Rebuild only dirty grids; everything else stays cached. An empty + -- rebuild result (1 or 0 members → no MST edges) drops the grid from + -- the cache entirely. + for gk, info in pairs(pendingGridDirty) do + local newMst = BuildGridMST(info.ally, info.gridID) + if next(newMst) then + mstByGrid[gk] = { ally = info.ally, gridID = info.gridID, edges = newMst } + else + mstByGrid[gk] = nil + end + pendingGridDirty[gk] = nil + end + + -- 4) Compose the desired edge set from cached MSTs. local newEdges = {} - for _, info in pairs(gridsToBuild) do - local mst = BuildGridMST(info.allyTeamID, info.gridID) - for ek, einfo in pairs(mst) do + for _, mst in pairs(mstByGrid) do + for ek, einfo in pairs(mst.edges) do newEdges[ek] = einfo end end - -- Diff: drop missing, add new. Survivors keep their entry (and their - -- ComputeMaxPotentials reorientation) untouched. + -- 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 @@ -413,6 +461,7 @@ local function SyncWithGrid() px = einfo.px, pz = einfo.pz, cx = einfo.cx, cz = einfo.cz, } topologyDirty = true + mpCache.valid = false end end end @@ -430,21 +479,25 @@ end -- the (finite) sum of neededlink thresholds. ------------------------------------------------------------------------------------- -local function ComputeMaxPotentials() - -- Treat edges as undirected: stored parent/child reflects MST traversal - -- order at the time the edge was inserted, NOT actual energy flow. We - -- re-derive both subtree sums and parent/child orientation here, then - -- write the orientation back so downstream rendering animates correctly. - local adj = {} -- [unitID] = { {neigh = id, key = ek}, ... } +-- 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 = {} - local function nodeUnitDefID(uid) - for _, allyNodes in pairs(nodes) do - local n = allyNodes[uid] - if n then return n.unitDefID end - end - return nil - end - 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 } @@ -453,10 +506,8 @@ local function ComputeMaxPotentials() nodeSet[b] = true end - -- Per-component DFS rooted at the highest-Pmax node in that component. - -- parentInTree maps child -> { parent, edgeKey } so subtree sums are well-defined. local parentInTree = {} - local order = {} -- DFS visit order, used for post-order pass + local order = {} local visited = {} local function dfsRoot(rootID) @@ -482,7 +533,6 @@ local function ComputeMaxPotentials() for uid in pairs(nodeSet) do if not visited[uid] then - -- pick best root within this component: highest Pmax local componentNodes = {} local stk = { uid } local seen = { [uid] = true } @@ -500,7 +550,7 @@ local function ComputeMaxPotentials() local bestID, bestP = uid, -1 for i = 1, #componentNodes do local v = componentNodes[i] - local did = nodeUnitDefID(v) + local did = nodeDefByUID[v] local p = did and GetNodePmax(did) or 0 if p > bestP then bestP = p; bestID = v end end @@ -508,35 +558,113 @@ local function ComputeMaxPotentials() end end - -- Post-order traversal: subPmax/subDmax of each node's subtree (inclusive). - -- Same pass also accumulates subPcur/subDcur using current rules-params, - -- so we can derive both nameplate capacity and live flow per edge below. - local subPmax, subDmax = {}, {} - local subPcur, subDcur = {}, {} + -- 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 did = nodeUnitDefID(u) + 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 - subPcur[u] = did and GetNodePcurrent(u, did) or 0 + if did and isWindgenByDef[did] then + subWindCount[u] = 1 + subWindBase[u] = spGetUnitRulesParam(u, "minWind") or 0 + 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 + +local function ComputeMaxPotentials() + if not mpCache.valid then BuildMpCache() end + local order = mpCache.order + local parentInTree = mpCache.parentInTree + local componentRoot = mpCache.componentRoot + local subPmax = mpCache.subPmax + local subDmax = mpCache.subDmax + local subPmaxNonWind = mpCache.subPmaxNonWind + local subWindCount = mpCache.subWindCount + local subWindBase = mpCache.subWindBase + + -- Per-tick wind globals: one read each, then everything is arithmetic. + local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 + local _, _, _, currStrength = Spring.GetWind() + currStrength = currStrength or 0 + local windFrac = (windMax > 0) and (currStrength / windMax) or 0 + if windFrac < 0 then windFrac = 0 elseif windFrac > 1 then windFrac = 1 end + + -- subPcur derived directly from cached aggregates — no per-node Pcur read. + -- Wind: linear-in-strength sum collapses to (1-f)*base + f*windMax*N. + -- Non-wind generators in MST are assumed to be producing nameplate + -- (any inactive generator has gridID=0 and so isn't in the cached tree). + local subPcur = {} + for i = 1, #order do + local u = order[i] + subPcur[u] = subWindBase[u] + windFrac * (windMax * subWindCount[u] - subWindBase[u]) + + subPmaxNonWind[u] + end + + -- subDcur DOES still need per-node reads — mex draw and turret + -- consumption fluctuate per tick and are not derivable from anything + -- topology-cached. + local subDcur = {} + for i = 1, #order do + local u = order[i] + local did = nodeDefByUID[u] subDcur[u] = 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 - subPmax[pi.parent] = subPmax[pi.parent] + subPmax[u] - subDmax[pi.parent] = subDmax[pi.parent] + subDmax[u] - subPcur[pi.parent] = subPcur[pi.parent] + subPcur[u] subDcur[pi.parent] = subDcur[pi.parent] + subDcur[u] end end - -- Per edge: subtree side = the deeper node (the child in DFS rooting). - -- capAB = max flow subtree -> other; capBA = max flow other -> subtree. - -- Whichever is larger sets parent on the source side. - -- `flow` (current) is computed in the same direction the cable is oriented: - -- flow = min(srcPcur, dstDcur) on the chosen flow side. + -- 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 @@ -545,32 +673,22 @@ local function ComputeMaxPotentials() for cid, info in pairs(parentInTree) do local key = info.key local pid = info.parent - -- Find root of cid's component (walk up parentInTree). - local r = cid - while parentInTree[r] do r = parentInTree[r].parent end + local r = componentRoot[cid] - -- Max-potential capacity (drives cable thickness). Symmetric: pick the - -- larger of the two cut-flows; the one that wins also names the - -- "potential source" side. 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 -- subtree -> other - local capBA = (oP < sD) and oP or sD -- other -> subtree + 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 -- fallback orientation when flow == 0 + local potentialSrcSubtree = capAB > capBA - -- Current flow: same min-cut math against *live* production / draw. - -- We compute both directions; the winner sets both magnitude and - -- direction. This matters when max-potential and current direction - -- disagree — e.g., a stunned fusion has Pmax > 0 but Pcurrent == 0, - -- so a small solar on the other side actually drives flow. 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 -- subtree -> other - local flowBA = (oPc < sDc) and oPc or sDc -- other -> subtree + local flowAB = (sPc < oDc) and sPc or oDc + local flowBA = (oPc < sDc) and oPc or sDc local flow, flowSrcSubtree if flowAB >= flowBA then flow, flowSrcSubtree = flowAB, true @@ -578,31 +696,18 @@ local function ComputeMaxPotentials() flow, flowSrcSubtree = flowBA, false end if flow < 0 then flow = 0 end - -- At zero current flow the cable still has a meaningful "intended" - -- direction: the side that would draw if it could. For an idle turret - -- (voltage unit, Pcurrent=0, Dcurrent=0 but Dmax>0), that's the - -- turret side. Use the max-potential orientation as fallback so the - -- cable visually points AT the consumer; bubbles are motionless - -- anyway because flow == 0. - if flow <= 0 then - flowSrcSubtree = potentialSrcSubtree - end + if flow <= 0 then flowSrcSubtree = potentialSrcSubtree end flows[key] = flow - -- Orient: parent = source side (current flow when nonzero, max- - -- potential otherwise — handled by the fallback above). 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 function findNode(uid) - for _, allyNodes in pairs(nodes) do - local n = allyNodes[uid] - if n then return n end - end - end - local np, nc = findNode(newParent), findNode(newChild) + 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 @@ -612,14 +717,12 @@ local function ComputeMaxPotentials() end if debugLog then - local function nameOf(uid) - local d = nodeUnitDefID(uid) - return d and UnitDefs[d].name or tostring(uid) - end local e = edges[key] - debugLog[#debugLog + 1] = string.format(" %-13s -> %-13s sP=%-7.1f sD=%-5s oP=%-7.1f oD=%-5s cap=%.1f flow=%.1f", - nameOf(e.parentID), nameOf(e.childID), - sP, fmtD(sD), oP, fmtD(oD), cap, flow) + 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 @@ -788,6 +891,7 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam) unitDefID = unitDefID, } allyOfUnit[unitID] = allyTeamID + nodeDefByUID[unitID] = unitDefID end function gadget:UnitDestroyed(unitID, unitDefID, unitTeam) @@ -802,9 +906,13 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) local _, _, _, _, _, oldAlly = Spring.GetTeamInfo(oldTeam, false) if not newAlly or not oldAlly then return end if newAlly ~= oldAlly then + -- Old ally's grid loses a member: dirty it before we forget which + -- grid this unit was in. + MarkGridDirty(oldAlly, lastGridNum[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] = { @@ -813,6 +921,7 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) unitDefID = unitDefID, } allyOfUnit[unitID] = newAlly + nodeDefByUID[unitID] = unitDefID end end end @@ -831,6 +940,7 @@ function gadget:Initialize() unitDefID = unitDefID, } allyOfUnit[unitID] = allyTeamID + nodeDefByUID[unitID] = unitDefID end end end @@ -915,8 +1025,23 @@ end -- edgesByAllyTeam[ally][edgeKey] = { px, pz, cx, cz, capacity, appearFrame, witherFrame } local edgesByAllyTeam = {} local renderEdges = {} +local renderEdgesByKey = {} -- flat lookup: edgeKey -> renderEdge entry local needsRebuild = false +-- 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 @@ -986,10 +1111,39 @@ end -- Build organic tree geometry from renderEdges (full edges; growth/wither -- is animated in the fragment shader via appearTime / witherTime). -local function GenerateOrganicTree() - if #renderEdges == 0 then return {}, 0 end +-- Generic angle clustering: groups items whose angles are within MERGE_ANGLE +-- of an immediate neighbour (after sorting). Handles wrap-around. +local function clusterByAngle(items) + if #items == 0 then return {} end + table.sort(items, function(a, b) return (a.angle or 0) < (b.angle or 0) end) + local clusters = { { items[1] } } + for i = 2, #items do + local cur = clusters[#clusters] + if abs(normalizeAngle(items[i].angle - items[i-1].angle)) < MERGE_ANGLE then + cur[#cur + 1] = items[i] + else + clusters[#clusters + 1] = { items[i] } + end + end + if #clusters > 1 then + local first, last = clusters[1], clusters[#clusters] + if abs(normalizeAngle(first[1].angle - last[#last].angle)) < MERGE_ANGLE then + for i = 1, #last do first[#first + 1] = last[i] end + clusters[#clusters] = nil + end + end + return clusters +end +-- Phase 1: heavy build. Walks renderEdges, clusters per-node, emits noisy +-- paths + twigs + cluster stems. Each `emitNoisyPath` call produces one or +-- more allPaths entries that all share a single `prov` object — the prov +-- carries the dynamic fields (flow/eff/bubblePhase/appear/wither) and is +-- refreshed in-place on cache-hit calls so we don't need to regenerate +-- geometry on every send. +local function BuildAllPaths() local allPaths = {} + local provs = {} local nodePos = {} -- Undirected adjacency: every edge contributes one entry to each endpoint. @@ -1010,22 +1164,15 @@ local function GenerateOrganicTree() nodeNeighbors[pk] = nodeNeighbors[pk] or {} nodeNeighbors[ck] = nodeNeighbors[ck] or {} local cap = max(1, e.capacity) - local flow = e.flow or 0 - local eff = e.eff or 0 - local bubblePhase = e.bubblePhase or 0 nodeNeighbors[pk][#nodeNeighbors[pk] + 1] = { - nKey = ck, edgeIdx = i, side = 1, cap = cap, flow = flow, eff = eff, - bubblePhase = bubblePhase, - appearFrame = e.appearFrame, witherFrame = e.witherFrame, + nKey = ck, edgeIdx = i, side = 1, cap = cap, } nodeNeighbors[ck][#nodeNeighbors[ck] + 1] = { - nKey = pk, edgeIdx = i, side = 2, cap = cap, flow = flow, eff = eff, - bubblePhase = bubblePhase, - appearFrame = e.appearFrame, witherFrame = e.witherFrame, + nKey = pk, edgeIdx = i, side = 2, cap = cap, } end - local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, appearFrame, witherFrame, flow, eff, bubblePhase) + local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, prov) local path = NoisyPath(x1, z1, x2, z2, NOISE_AMP_ABS, seed) local widths = {} for pi = 1, #path do @@ -1035,13 +1182,10 @@ local function GenerateOrganicTree() allPaths[#allPaths + 1] = { points = path, widths = widths, capacity = capacity, isBranch = isBranch and 1 or 0, - appearFrame = appearFrame, witherFrame = witherFrame, - flow = flow or 0, eff = eff or 0, - bubblePhase = bubblePhase or 0, + prov = prov, } - -- Twigs: spawn from ribbon edge, not center. Twigs are decorative; - -- they share the parent's flow/eff so pulse animation is consistent - -- across the visual cluster. + -- Twigs: spawn from ribbon edge, not center. They share the parent's + -- prov so dynamic fields stay consistent across the visual cluster. for pi = 2, #path - 1 do local p1 = path[pi] local w = widths[pi] @@ -1058,7 +1202,6 @@ local function GenerateOrganicTree() local angle = baseAngle + side * (BRANCH_ANGLE_MIN + HashUnit(p1.x, p1.z, tseed + 2) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN)) local bLen = (BRANCH_LEN_MIN + HashUnit(p1.x, p1.z, tseed + 3) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN)) * lenScale - -- Offset start point to ribbon edge (perpendicular to path direction) local perpX = -dz / pathLen * side local perpZ = dx / pathLen * side local edgeX = p1.x + perpX * w * 0.45 @@ -1069,7 +1212,6 @@ local function GenerateOrganicTree() local bw = w * BRANCH_WIDTH * (isBranch and 0.6 or 1.0) local twigPts = NoisyPath(edgeX, edgeZ, bx2, bz2, NOISE_AMP_ABS * 0.7, tseed + 10) local twigWidths = {} - -- Start at parent width, taper to thin tip twigWidths[1] = min(bw, w * 0.4) for ti = 2, #twigPts do local tt = (ti - 1) / max(1, #twigPts - 1) @@ -1078,43 +1220,17 @@ local function GenerateOrganicTree() allPaths[#allPaths + 1] = { points = twigPts, widths = twigWidths, capacity = capacity, isBranch = 1, - appearFrame = appearFrame, witherFrame = witherFrame, - flow = flow or 0, eff = eff or 0, - bubblePhase = bubblePhase or 0, + prov = prov, } end end end - -- Generic angle clustering: groups items whose angles are within MERGE_ANGLE - -- of an immediate neighbour (after sorting). Handles wrap-around. - local function clusterByAngle(items) - if #items == 0 then return {} end - table.sort(items, function(a, b) return (a.angle or 0) < (b.angle or 0) end) - local clusters = { { items[1] } } - for i = 2, #items do - local cur = clusters[#clusters] - if abs(normalizeAngle(items[i].angle - items[i-1].angle)) < MERGE_ANGLE then - cur[#cur + 1] = items[i] - else - clusters[#clusters + 1] = { items[i] } - end - end - if #clusters > 1 then - local first, last = clusters[1], clusters[#clusters] - if abs(normalizeAngle(first[1].angle - last[#last].angle)) < MERGE_ANGLE then - for i = 1, #last do first[#first + 1] = last[i] end - clusters[#clusters] = nil - end - end - return clusters - end - -- For each node, cluster all incident half-edges by direction. A cluster -- of >=2 emits a stem cable from the node along the cluster's average -- direction; every edge in that cluster gets the stem-end as its attach -- point on this side. Singletons attach directly at the node. - local edgeAttach = {} -- [edgeIdx][side] = {x, z, hasStem, stemW} + local edgeAttach = {} for nk, nbrs in pairs(nodeNeighbors) do local pos = nodePos[nk] for i = 1, #nbrs do @@ -1133,12 +1249,9 @@ local function GenerateOrganicTree() edgeAttach[n.edgeIdx] = edgeAttach[n.edgeIdx] or {} edgeAttach[n.edgeIdx][n.side] = { x = pos.x, z = pos.z, hasStem = false } else - -- Aggregate cluster data: average direction, summed cap, weighted - -- mean flow/eff, and *signed* flow (positive when flow leaves the - -- node along this cluster, negative when it enters). The sign - -- decides whether the stem path is emitted node->stemTip - -- (outward) or stemTip->node (inward), so pulses always travel - -- in the actual direction of energy flow. + -- Aggregate cluster geometry. Dynamic fields are computed once + -- here for the initial prov; refresh path will re-read them + -- from renderEdgesByKey via prov.members on cache-hit calls. local avgCos, avgSin, clusterCap, minDist = 0, 0, 0, math.huge local clusterFlow = 0 local netFlowSigned = 0 @@ -1147,26 +1260,32 @@ local function GenerateOrganicTree() local stemAppear = math.huge local stemWither = -math.huge local allWither = true + local members = {} for i = 1, #cluster do local n = cluster[i] + local re = renderEdges[n.edgeIdx] + local f = re.flow or 0 + local effv = re.eff or 0 + local phasev = re.bubblePhase or 0 avgCos = avgCos + cos(n.angle) avgSin = avgSin + sin(n.angle) clusterCap = clusterCap + n.cap - clusterFlow = clusterFlow + (n.flow or 0) + clusterFlow = clusterFlow + f -- side=1 → this node is the parent (source) → flow leaves -- side=2 → this node is the child (sink) → flow enters - netFlowSigned = netFlowSigned + ((n.side == 1) and (n.flow or 0) or -(n.flow or 0)) - effSum = effSum + (n.eff or 0) * n.cap - phaseSum = phaseSum + (n.bubblePhase or 0) * n.cap + netFlowSigned = netFlowSigned + ((n.side == 1) and f or -f) + effSum = effSum + effv * n.cap + phaseSum = phaseSum + phasev * n.cap capForEff = capForEff + n.cap if n.dist and n.dist < minDist then minDist = n.dist end - local af = n.appearFrame or 0 + local af = re.appearFrame or 0 if af < stemAppear then stemAppear = af end - if n.witherFrame then - if n.witherFrame > stemWither then stemWither = n.witherFrame end + if re.witherFrame then + if re.witherFrame > stemWither then stemWither = re.witherFrame end else allWither = false end + members[#members + 1] = { key = re.key, side = n.side, cap = n.cap } end if stemAppear == math.huge then stemAppear = 0 end local stemWitherFinal = (allWither and stemWither > -math.huge) and stemWither or nil @@ -1176,25 +1295,29 @@ local function GenerateOrganicTree() local stemX = pos.x + cos(avgAngle) * stemLen local stemZ = pos.z + sin(avgAngle) * stemLen local stemW = GetTrunkWidth(clusterCap) - local stemEff = capForEff > 0 and (effSum / capForEff) or 0 - local stemPhase = capForEff > 0 and (phaseSum / capForEff) or 0 - - -- Stem is wider at the node side (where the cables merge) and a - -- bit thinner at the tip — emit accordingly so the merged trunk - -- reads visually regardless of flow direction. local outward = netFlowSigned >= 0 + + local prov = { + kind = "stem", + members = members, + capForEff = capForEff, + outward = outward, + flow = clusterFlow, + eff = (capForEff > 0) and (effSum / capForEff) or 0, + bubblePhase = (capForEff > 0) and (phaseSum / capForEff) or 0, + appearFrame = stemAppear, + witherFrame = stemWitherFinal, + } + provs[#provs + 1] = prov + if outward then emitNoisyPath(pos.x, pos.z, stemX, stemZ, stemW, stemW * 0.9, clusterCap, - pos.x + pos.z + ci * 7.3, - false, stemAppear, stemWitherFinal, - clusterFlow, stemEff, stemPhase) + pos.x + pos.z + ci * 7.3, false, prov) else emitNoisyPath(stemX, stemZ, pos.x, pos.z, stemW * 0.9, stemW, clusterCap, - pos.x + pos.z + ci * 7.3, - false, stemAppear, stemWitherFinal, - clusterFlow, stemEff, stemPhase) + pos.x + pos.z + ci * 7.3, false, prov) end for i = 1, #cluster do @@ -1224,21 +1347,82 @@ local function GenerateOrganicTree() end local startW = endWidth(attach[1]) local endW = endWidth(attach[2]) - -- Seed must be stable across VBO rebuilds, otherwise the noise - -- pattern reshuffles every send (~1 Hz) and the eye reads it as - -- the animation "resetting". Use deterministic coords of both - -- endpoints — independent of pairs() iteration order. local seed = attach[1].x * 0.137 + attach[1].z * 0.781 + attach[2].x * 0.293 + attach[2].z * 0.461 + local prov = { + kind = "edge", + key = e.key, + flow = e.flow or 0, eff = e.eff or 0, + bubblePhase = e.bubblePhase or 0, + appearFrame = e.appearFrame, witherFrame = e.witherFrame, + } + provs[#provs + 1] = prov emitNoisyPath(attach[1].x, attach[1].z, attach[2].x, attach[2].z, - startW, endW, cap, seed, - false, e.appearFrame, e.witherFrame, - e.flow or 0, e.eff or 0, e.bubblePhase or 0) + startW, endW, cap, seed, false, prov) end end - -- Convert paths to triangle strip vertices (smooth ribbons with averaged normals) - -- Format per vertex: x, y, z, capacity, isBranch, width, u, v + return allPaths, provs +end + +-- Phase 2: refresh dynamic fields on cached provs from the current +-- renderEdgesByKey. A cluster sign-flip (net flow direction reversed) +-- invalidates the cache so the next call regenerates with the correct +-- emission direction. +local function RefreshProvs(provs) + for i = 1, #provs do + local p = provs[i] + if p.kind == "edge" then + local e = renderEdgesByKey[p.key] + if e then + p.flow = e.flow or 0 + p.eff = e.eff or 0 + p.bubblePhase = e.bubblePhase or 0 + p.appearFrame = e.appearFrame + p.witherFrame = e.witherFrame + end + else + local clusterFlow = 0 + local netFlowSigned = 0 + local effSum, phaseSum = 0, 0 + local stemAppear, stemWither = math.huge, -math.huge + local allWither = true + local members = p.members + for j = 1, #members do + local m = members[j] + local e = renderEdgesByKey[m.key] + if e then + local f = e.flow or 0 + clusterFlow = clusterFlow + f + netFlowSigned = netFlowSigned + ((m.side == 1) and f or -f) + effSum = effSum + (e.eff or 0) * m.cap + phaseSum = phaseSum + (e.bubblePhase or 0) * m.cap + local af = e.appearFrame or 0 + if af < stemAppear then stemAppear = af end + if e.witherFrame then + if e.witherFrame > stemWither then stemWither = e.witherFrame end + else + allWither = false + end + end + end + if (netFlowSigned >= 0) ~= p.outward then + geomCache.valid = false + end + p.flow = clusterFlow + p.eff = (p.capForEff > 0) and (effSum / p.capForEff) or 0 + p.bubblePhase = (p.capForEff > 0) and (phaseSum / p.capForEff) or 0 + if stemAppear == math.huge then stemAppear = 0 end + p.appearFrame = stemAppear + p.witherFrame = (allWither and stemWither > -math.huge) and stemWither or nil + end + end +end + +-- Phase 3: convert cached paths to triangle vertices. Reads dynamic fields +-- from path.prov (refreshed per-call). All other per-vertex data is purely +-- a function of (points, widths) and stays constant across cache-hit calls. +local function EmitVerts(allPaths) local verts = {} local vertCount = 0 @@ -1248,11 +1432,12 @@ local function GenerateOrganicTree() local wds = path.widths local cap = path.capacity local branch = path.isBranch - local appearTime = (path.appearFrame or 0) / GAME_SPEED - local witherTime = path.witherFrame and (path.witherFrame / GAME_SPEED) or 0 - local pathEff = path.eff or 0 - local pathFlow = path.flow or 0 - local pathPhase = path.bubblePhase or 0 + local prov = path.prov + local appearTime = (prov.appearFrame or 0) / GAME_SPEED + local witherTime = prov.witherFrame and (prov.witherFrame / GAME_SPEED) or 0 + local pathEff = prov.eff or 0 + local pathFlow = prov.flow or 0 + local pathPhase = prov.bubblePhase or 0 if #pts >= 2 then -- Averaged perpendicular at each waypoint @@ -1356,6 +1541,32 @@ local function GenerateOrganicTree() return verts, vertCount end +-- Top-level entrypoint. On topology-stable rebuilds, walks the cached provs +-- to refresh dynamic fields and re-emits verts using cached path geometry — +-- no NoisyPath, no clustering, no twig generation. On topology change, +-- rebuilds allPaths from scratch. +local function GenerateOrganicTree() + if #renderEdges == 0 then + geomCache.valid = false + geomCache.allPaths = nil + geomCache.provs = nil + return {}, 0 + end + + if geomCache.valid then + RefreshProvs(geomCache.provs) + end + -- RefreshProvs may flip geomCache.valid off if it detected a sign change. + if not geomCache.valid then + local allPaths, provs = BuildAllPaths() + geomCache.allPaths = allPaths + geomCache.provs = provs + geomCache.valid = true + end + + return EmitVerts(geomCache.allPaths) +end + ------------------------------------------------------------------------------------- -- Forward cable rendering via DrawWorldPreUnit. @@ -1714,9 +1925,12 @@ end local function RebuildRenderEdges() renderEdges = {} + renderEdgesByKey = {} for _, edges in pairs(edgesByAllyTeam) do - for _, e in pairs(edges) do + for k, e in pairs(edges) do + e.key = k renderEdges[#renderEdges + 1] = e + renderEdgesByKey[k] = e end end end @@ -1743,6 +1957,7 @@ local function OnCableTreeFull() for k, e in pairs(existing) do if not incoming[k] and not e.witherFrame then e.witherFrame = frame + geomCache.valid = false -- topology change → full geometry rebuild end end @@ -1779,12 +1994,14 @@ local function OnCableTreeFull() eff = data.effs and data.effs[i] or 0, appearFrame = frame, witherFrame = nil, + key = k, -- 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), } + geomCache.valid = false -- topology change → full geometry rebuild end end @@ -1813,6 +2030,7 @@ local function RebuildVBO() end local tGen0 = drawPerf and Spring.GetTimer() or nil + local cacheHit = geomCache.valid local verts, vertCount = GenerateOrganicTree() if vertCount == 0 then numCableVerts = 0 @@ -1841,7 +2059,8 @@ local function RebuildVBO() if drawPerf then local tEnd = Spring.GetTimer() Spring.Echo(string.format( - "[CableTree] draw rebuild: phase=%.2f ms geom=%.2f ms upload=%.2f ms verts=%d", + "[CableTree] draw rebuild (%s): phase=%.2f ms geom=%.2f ms upload=%.2f ms verts=%d", + cacheHit and "cache-hit" or "FULL", Spring.DiffTimers(tGen0, tStart) * 1000, Spring.DiffTimers(tUp0, tGen0) * 1000, Spring.DiffTimers(tEnd, tUp0) * 1000, @@ -1872,6 +2091,7 @@ function gadget:GameFrame(n) if dropped then RebuildRenderEdges() needsRebuild = true + geomCache.valid = false end if needsRebuild and n % 6 == 0 then From 27451ecae6782493767e20befb989b83cba447bd Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 12:10:48 +0200 Subject: [PATCH 24/59] debug bridge --- LuaUI/Widgets/dbg_claude_bridge.lua | 467 ++++++++++++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 LuaUI/Widgets/dbg_claude_bridge.lua diff --git a/LuaUI/Widgets/dbg_claude_bridge.lua b/LuaUI/Widgets/dbg_claude_bridge.lua new file mode 100644 index 0000000000..356cf154c1 --- /dev/null +++ b/LuaUI/Widgets/dbg_claude_bridge.lua @@ -0,0 +1,467 @@ +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +function widget:GetInfo() + return { + name = "Claude Bridge", + desc = "TCP bridge for external tooling: exec lua, run Spring console commands, stream Spring.Echo. Listens on 127.0.0.1:8200.", + author = "Licho + Claude", + date = "2026-04-29", + license = "GPLv2", + layer = 0, + enabled = true, + } +end + +-------------------------------------------------------------------------------- +-- Pre-flight: LuaSocket must be enabled in springsettings.cfg. +-------------------------------------------------------------------------------- + +if not (Spring.GetConfigInt("LuaSocketEnabled", 0) == 1) then + Spring.Echo("[ClaudeBridge] LuaSocketEnabled=0 - widget inactive. Add 'LuaSocketEnabled = 1' and 'TCPAllowListen = 127.0.0.1:8200' to springsettings.cfg.") + return false +end + +local socket = socket +if not socket then + Spring.Echo("[ClaudeBridge] socket library not available") + return false +end + +-------------------------------------------------------------------------------- +-- Minimal JSON (inline; LuaRules/Utilities/json.lua relies on _G which the +-- LuaUI widget sandbox does not expose). +-------------------------------------------------------------------------------- + +local _byte, _sub, _format, _gsub, _char = string.byte, string.sub, string.format, string.gsub, string.char + +local function encStr(s) + s = _gsub(s, "\\", "\\\\") + s = _gsub(s, "\"", "\\\"") + s = _gsub(s, "\n", "\\n") + s = _gsub(s, "\r", "\\r") + s = _gsub(s, "\t", "\\t") + s = _gsub(s, "[%z\1-\31\127]", function(c) return _format("\\u%04x", _byte(c)) end) + return "\"" .. s .. "\"" +end + +local jsonEncode +jsonEncode = function(v) + local t = type(v) + if v == nil then return "null" end + if t == "boolean" then return v and "true" or "false" end + if t == "number" then + if v ~= v or v == math.huge or v == -math.huge then return "null" end + return tostring(v) + end + if t == "string" then return encStr(v) end + if t == "table" then + local n = #v + local cnt = 0 + for _ in pairs(v) do cnt = cnt + 1 end + local isArr = (n > 0 and cnt == n) + if isArr then + local parts = {} + for i = 1, n do parts[i] = jsonEncode(v[i]) end + return "[" .. table.concat(parts, ",") .. "]" + end + if cnt == 0 then return "{}" end + local parts = {} + for k, val in pairs(v) do + parts[#parts + 1] = encStr(tostring(k)) .. ":" .. jsonEncode(val) + end + return "{" .. table.concat(parts, ",") .. "}" + end + return "null" +end + +local jsonDecodeValue + +local function skipWs(s, i) + while true do + local c = _byte(s, i) + if c == 32 or c == 9 or c == 10 or c == 13 then i = i + 1 + else return i end + end +end + +local function decodeStr(s, i) + i = i + 1 + local out = {} + while true do + local c = _byte(s, i) + if not c then error("unterminated string") end + if c == 34 then return table.concat(out), i + 1 end + if c == 92 then + local n = _byte(s, i + 1) + if n == 110 then out[#out + 1] = "\n" + elseif n == 114 then out[#out + 1] = "\r" + elseif n == 116 then out[#out + 1] = "\t" + elseif n == 98 then out[#out + 1] = "\b" + elseif n == 102 then out[#out + 1] = "\f" + elseif n == 34 then out[#out + 1] = "\"" + elseif n == 47 then out[#out + 1] = "/" + elseif n == 92 then out[#out + 1] = "\\" + elseif n == 117 then + local code = tonumber(_sub(s, i + 2, i + 5), 16) + if code and code < 128 then out[#out + 1] = _char(code) + else out[#out + 1] = "?" end + i = i + 4 + else error("bad escape") end + i = i + 2 + else + out[#out + 1] = _sub(s, i, i) + i = i + 1 + end + end +end + +local function decodeNum(s, i) + local j = i + while true do + local c = _byte(s, j) + if c and ((c >= 48 and c <= 57) or c == 45 or c == 43 or c == 46 or c == 101 or c == 69) then + j = j + 1 + else break end + end + return tonumber(_sub(s, i, j - 1)), j +end + +local function decodeArr(s, i) + i = skipWs(s, i + 1) + local out = {} + if _byte(s, i) == 93 then return out, i + 1 end + while true do + local v + v, i = jsonDecodeValue(s, i) + out[#out + 1] = v + i = skipWs(s, i) + local c = _byte(s, i) + if c == 44 then i = skipWs(s, i + 1) + elseif c == 93 then return out, i + 1 + else error("expected , or ]") end + end +end + +local function decodeObj(s, i) + i = skipWs(s, i + 1) + local out = {} + if _byte(s, i) == 125 then return out, i + 1 end + while true do + if _byte(s, i) ~= 34 then error("expected key string") end + local k + k, i = decodeStr(s, i) + i = skipWs(s, i) + if _byte(s, i) ~= 58 then error("expected :") end + i = skipWs(s, i + 1) + local v + v, i = jsonDecodeValue(s, i) + out[k] = v + i = skipWs(s, i) + local c = _byte(s, i) + if c == 44 then i = skipWs(s, i + 1) + elseif c == 125 then return out, i + 1 + else error("expected , or }") end + end +end + +jsonDecodeValue = function(s, i) + i = skipWs(s, i) + local c = _byte(s, i) + if c == 34 then return decodeStr(s, i) end + if c == 123 then return decodeObj(s, i) end + if c == 91 then return decodeArr(s, i) end + if c == 116 and _sub(s, i, i + 3) == "true" then return true, i + 4 end + if c == 102 and _sub(s, i, i + 4) == "false" then return false, i + 5 end + if c == 110 and _sub(s, i, i + 3) == "null" then return nil, i + 4 end + if c == 45 or (c and c >= 48 and c <= 57) then return decodeNum(s, i) end + error("unexpected char at " .. tostring(i) .. ": " .. tostring(c)) +end + +local function jsonDecode(s) + local v = jsonDecodeValue(s, 1) + return v +end + +local json = { encode = jsonEncode, decode = jsonDecode } + +-------------------------------------------------------------------------------- +-- Config +-------------------------------------------------------------------------------- + +local HOST = "127.0.0.1" +local PORT = 8200 +local MAX_OUT_BUF = 1024 * 1024 -- 1 MB before we drop the client +local MAX_RESULT_LEN = 64 * 1024 -- truncate huge result strings +local MAX_TABLE_KEYS = 200 + +-------------------------------------------------------------------------------- +-- State +-------------------------------------------------------------------------------- + +local server -- listening socket +local clients = {} -- numeric list of sockets, used as set for socket.select +local stateBy = {} -- sock -> { sock, inBuf, outBuf, streamLogs, peer } + +-------------------------------------------------------------------------------- +-- Helpers +-------------------------------------------------------------------------------- + +local function writeFrame(c, frame) + local ok, encoded = pcall(json.encode, frame) + if not ok then return end + c.outBuf = c.outBuf .. encoded .. "\n" + if #c.outBuf > MAX_OUT_BUF then + c.overflowed = true + end +end + +local function prettyValue(v, depth, lenAcc) + depth = depth or 0 + if depth > 5 then return "<...>" end + local t = type(v) + if t == "string" then + if #v > MAX_RESULT_LEN then + return v:sub(1, MAX_RESULT_LEN) .. "..." + end + return v + end + if t == "nil" then return "nil" end + if t == "number" or t == "boolean" then return tostring(v) end + if t == "function" or t == "userdata" or t == "thread" then return "<" .. t .. ">" end + if t == "table" then + local parts = { "{" } + local pad = string.rep(" ", depth + 1) + local count = 0 + for k, val in pairs(v) do + count = count + 1 + if count > MAX_TABLE_KEYS then + parts[#parts + 1] = pad .. "...(" .. count .. "+ entries)" + break + end + local keyStr = (type(k) == "string" and ("[" .. string.format("%q", k) .. "]")) or ("[" .. tostring(k) .. "]") + parts[#parts + 1] = pad .. keyStr .. " = " .. prettyValue(val, depth + 1) .. "," + end + parts[#parts + 1] = string.rep(" ", depth) .. "}" + return table.concat(parts, "\n") + end + return tostring(v) +end + +local execEnv -- shared sandbox for repeated EXEC frames so locals persist + +local function getExecEnv() + if not execEnv then + execEnv = setmetatable({}, { __index = getfenv(1) }) + end + return execEnv +end + +local function runLua(code, asExpression) + local fn, err + if asExpression then + fn = loadstring("return " .. code, "claude_bridge") + end + if not fn then + fn, err = loadstring(code, "claude_bridge") + end + if not fn then return false, err end + setfenv(fn, getExecEnv()) + local results = { pcall(fn) } + local ok = table.remove(results, 1) + if not ok then return false, results[1] end + if #results == 0 then return true, nil end + if #results == 1 then return true, results[1] end + return true, results +end + +-------------------------------------------------------------------------------- +-- Frame dispatch +-------------------------------------------------------------------------------- + +local function handleFrame(c, frame) + local id = frame.id + local kind = frame.kind + + if kind == "ping" then + writeFrame(c, { id = id, kind = "result", ok = true, value = "pong" }) + + elseif kind == "exec" then + -- Run as statement (no auto-return). Captures the explicit return value. + local ok, ret = runLua(frame.code or "", false) + if ok then + writeFrame(c, { id = id, kind = "result", ok = true, value = prettyValue(ret) }) + else + writeFrame(c, { id = id, kind = "result", ok = false, error = tostring(ret) }) + end + + elseif kind == "eval" then + -- Try as expression first (so 'eval Spring.GetGameFrame()' works), then statement. + local ok, ret = runLua(frame.code or "", true) + if ok then + writeFrame(c, { id = id, kind = "result", ok = true, value = prettyValue(ret) }) + else + writeFrame(c, { id = id, kind = "result", ok = false, error = tostring(ret) }) + end + + elseif kind == "cmd" then + local cmd = frame.code or frame.cmd or "" + Spring.SendCommands(cmd) + writeFrame(c, { id = id, kind = "result", ok = true, value = "" }) + + elseif kind == "log_subscribe" then + c.streamLogs = true + writeFrame(c, { id = id, kind = "result", ok = true, value = "log streaming on" }) + + elseif kind == "log_unsubscribe" then + c.streamLogs = false + writeFrame(c, { id = id, kind = "result", ok = true, value = "log streaming off" }) + + elseif kind == "screenshot" then + Spring.SendCommands("screenshot " .. (frame.format or "png")) + writeFrame(c, { id = id, kind = "result", ok = true, value = "screenshot triggered" }) + + elseif kind == "info" then + writeFrame(c, { id = id, kind = "result", ok = true, value = { + gameFrame = Spring.GetGameFrame(), + gameSeconds = Spring.GetGameSeconds(), + myTeamID = Spring.GetMyTeamID(), + myPlayerID = Spring.GetMyPlayerID(), + isReplay = Spring.IsReplay(), + } }) + + else + writeFrame(c, { id = id, kind = "result", ok = false, error = "unknown kind: " .. tostring(kind) }) + end +end + +local function processIncoming(c) + while true do + local nl = c.inBuf:find("\n", 1, true) + if not nl then break end + local line = c.inBuf:sub(1, nl - 1) + c.inBuf = c.inBuf:sub(nl + 1) + if line ~= "" and line ~= "\r" then + if line:sub(-1) == "\r" then line = line:sub(1, -2) end + local ok, frame = pcall(json.decode, line) + if ok and type(frame) == "table" then + local handlerOk, handlerErr = pcall(handleFrame, c, frame) + if not handlerOk then + writeFrame(c, { id = frame.id, kind = "result", ok = false, error = "handler crashed: " .. tostring(handlerErr) }) + end + else + writeFrame(c, { kind = "error", error = "bad json: " .. line:sub(1, 120) }) + end + end + end +end + +-------------------------------------------------------------------------------- +-- Client lifecycle +-------------------------------------------------------------------------------- + +local function closeClient(sock, why) + local c = stateBy[sock] + if not c then return end + pcall(function() sock:close() end) + stateBy[sock] = nil + for i = #clients, 1, -1 do + if clients[i] == sock then table.remove(clients, i) end + end + Spring.Echo(string.format("[ClaudeBridge] client %s disconnected (%s)", c.peer or "?", why or "closed")) +end + +local function acceptOne() + if not server then return end + local newSock = server:accept() + if not newSock then return end + newSock:settimeout(0) + local ip, port = newSock:getpeername() + local peer = string.format("%s:%s", tostring(ip), tostring(port)) + local c = { sock = newSock, inBuf = "", outBuf = "", streamLogs = false, peer = peer } + stateBy[newSock] = c + clients[#clients + 1] = newSock + Spring.Echo(string.format("[ClaudeBridge] client %s connected", peer)) + writeFrame(c, { kind = "hello", value = "claude-bridge ready", gameFrame = Spring.GetGameFrame() }) +end + +-------------------------------------------------------------------------------- +-- Widget callins +-------------------------------------------------------------------------------- + +function widget:Initialize() + server = socket.bind(HOST, PORT) + if not server then + Spring.Echo(string.format("[ClaudeBridge] cannot bind %s:%d - check TCPAllowListen in springsettings.cfg", HOST, PORT)) + widgetHandler:RemoveWidget() + return + end + server:settimeout(0) + Spring.Echo(string.format("[ClaudeBridge] listening on %s:%d", HOST, PORT)) +end + +function widget:Shutdown() + if server then pcall(function() server:close() end); server = nil end + for sock, _ in pairs(stateBy) do pcall(function() sock:close() end) end + stateBy = {} + clients = {} +end + +function widget:AddConsoleLine(line, priority) + if not line or line == "" then return end + -- Skip our own bridge chatter to avoid feedback loops in the streaming output. + if line:find("[ClaudeBridge]", 1, true) then return end + for _, sock in ipairs(clients) do + local c = stateBy[sock] + if c and c.streamLogs and not c.overflowed then + writeFrame(c, { kind = "log", msg = line, priority = priority }) + end + end +end + +function widget:Update() + if not server then return end + acceptOne() + + if #clients == 0 then return end + + local readable, writable, err = socket.select(clients, clients, 0) + if err and err ~= "timeout" then + Spring.Echo("[ClaudeBridge] select error: " .. tostring(err)) + return + end + + for _, sock in ipairs(readable or {}) do + local data, status, partial = sock:receive("*a") + if status == "closed" then + closeClient(sock, "remote closed") + else + local chunk = data or partial + if chunk and chunk ~= "" then + local c = stateBy[sock] + if c then + c.inBuf = c.inBuf .. chunk + processIncoming(c) + end + end + end + end + + for _, sock in ipairs(writable or {}) do + local c = stateBy[sock] + if c then + if c.overflowed then + closeClient(sock, "outbuf overflow") + elseif c.outBuf ~= "" then + local n, sendErr, partial2 = sock:send(c.outBuf) + if not n and sendErr == "closed" then + closeClient(sock, "send closed") + elseif n then + c.outBuf = c.outBuf:sub(n + 1) + elseif partial2 then + c.outBuf = c.outBuf:sub(partial2 + 1) + end + end + end + end +end From b3bbd43b68c6352ade54ae65e435673cfe4ff6fd Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 12:49:31 +0200 Subject: [PATCH 25/59] gs first try --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 660 ++++++---------------- 1 file changed, 173 insertions(+), 487 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 1fe47c4fff..90a5dd4c56 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1109,464 +1109,44 @@ local function normalizeAngle(a) return a end --- Build organic tree geometry from renderEdges (full edges; growth/wither --- is animated in the fragment shader via appearTime / witherTime). --- Generic angle clustering: groups items whose angles are within MERGE_ANGLE --- of an immediate neighbour (after sorting). Handles wrap-around. -local function clusterByAngle(items) - if #items == 0 then return {} end - table.sort(items, function(a, b) return (a.angle or 0) < (b.angle or 0) end) - local clusters = { { items[1] } } - for i = 2, #items do - local cur = clusters[#clusters] - if abs(normalizeAngle(items[i].angle - items[i-1].angle)) < MERGE_ANGLE then - cur[#cur + 1] = items[i] - else - clusters[#clusters + 1] = { items[i] } - end - end - if #clusters > 1 then - local first, last = clusters[1], clusters[#clusters] - if abs(normalizeAngle(first[1].angle - last[#last].angle)) < MERGE_ANGLE then - for i = 1, #last do first[#first + 1] = last[i] end - clusters[#clusters] = nil - end - end - return clusters -end - --- Phase 1: heavy build. Walks renderEdges, clusters per-node, emits noisy --- paths + twigs + cluster stems. Each `emitNoisyPath` call produces one or --- more allPaths entries that all share a single `prov` object — the prov --- carries the dynamic fields (flow/eff/bubblePhase/appear/wither) and is --- refreshed in-place on cache-hit calls so we don't need to regenerate --- geometry on every send. -local function BuildAllPaths() - local allPaths = {} - local provs = {} - - local nodePos = {} - -- Undirected adjacency: every edge contributes one entry to each endpoint. - -- `side` is 1 for parent end, 2 for child end (used so each edge ends up - -- with one attach point on each side after clustering). - local nodeNeighbors = {} - - local function posKey(x, z) - return floor(x) .. ":" .. floor(z) - end - - for i = 1, #renderEdges do - local e = renderEdges[i] - local pk = posKey(e.px, e.pz) - local ck = posKey(e.cx, e.cz) - nodePos[pk] = { x = e.px, z = e.pz } - nodePos[ck] = { x = e.cx, z = e.cz } - nodeNeighbors[pk] = nodeNeighbors[pk] or {} - nodeNeighbors[ck] = nodeNeighbors[ck] or {} - local cap = max(1, e.capacity) - nodeNeighbors[pk][#nodeNeighbors[pk] + 1] = { - nKey = ck, edgeIdx = i, side = 1, cap = cap, - } - nodeNeighbors[ck][#nodeNeighbors[ck] + 1] = { - nKey = pk, edgeIdx = i, side = 2, cap = cap, - } - end - - local function emitNoisyPath(x1, z1, x2, z2, widthStart, widthEnd, capacity, seed, isBranch, prov) - local path = NoisyPath(x1, z1, x2, z2, NOISE_AMP_ABS, seed) - local widths = {} - for pi = 1, #path do - local t = (pi - 1) / max(1, #path - 1) - widths[pi] = widthStart + t * (widthEnd - widthStart) - end - allPaths[#allPaths + 1] = { - points = path, widths = widths, - capacity = capacity, isBranch = isBranch and 1 or 0, - prov = prov, - } - -- Twigs: spawn from ribbon edge, not center. They share the parent's - -- prov so dynamic fields stay consistent across the visual cluster. - for pi = 2, #path - 1 do - local p1 = path[pi] - local w = widths[pi] - local tseed = p1.x * 7.13 + p1.z * 3.77 - local chance = isBranch and (BRANCH_CHANCE * 0.5) or BRANCH_CHANCE - local lenScale = isBranch and 0.6 or 1.0 - if HashUnit(p1.x, p1.z, tseed) < chance then - local dx = x2 - x1 - local dz = z2 - z1 - local pathLen = sqrt(dx * dx + dz * dz) - if pathLen < 1 then pathLen = 1 end - local baseAngle = atan2(dz, dx) - local side = (Hash(p1.x, p1.z, tseed + 1) > 0) and 1 or -1 - local angle = baseAngle + side * (BRANCH_ANGLE_MIN + HashUnit(p1.x, p1.z, tseed + 2) * (BRANCH_ANGLE_MAX - BRANCH_ANGLE_MIN)) - local bLen = (BRANCH_LEN_MIN + HashUnit(p1.x, p1.z, tseed + 3) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN)) * lenScale - - local perpX = -dz / pathLen * side - local perpZ = dx / pathLen * side - local edgeX = p1.x + perpX * w * 0.45 - local edgeZ = p1.z + perpZ * w * 0.45 - - local bx2 = edgeX + cos(angle) * bLen - local bz2 = edgeZ + sin(angle) * bLen - local bw = w * BRANCH_WIDTH * (isBranch and 0.6 or 1.0) - local twigPts = NoisyPath(edgeX, edgeZ, bx2, bz2, NOISE_AMP_ABS * 0.7, tseed + 10) - local twigWidths = {} - twigWidths[1] = min(bw, w * 0.4) - for ti = 2, #twigPts do - local tt = (ti - 1) / max(1, #twigPts - 1) - twigWidths[ti] = twigWidths[1] * (1 - tt * 0.8) - end - allPaths[#allPaths + 1] = { - points = twigPts, widths = twigWidths, - capacity = capacity, isBranch = 1, - prov = prov, - } - end - end - end - - -- For each node, cluster all incident half-edges by direction. A cluster - -- of >=2 emits a stem cable from the node along the cluster's average - -- direction; every edge in that cluster gets the stem-end as its attach - -- point on this side. Singletons attach directly at the node. - local edgeAttach = {} - for nk, nbrs in pairs(nodeNeighbors) do - local pos = nodePos[nk] - for i = 1, #nbrs do - local n = nbrs[i] - local npos = nodePos[n.nKey] - if npos then - n.angle = atan2(npos.z - pos.z, npos.x - pos.x) - n.dist = sqrt((npos.x - pos.x)^2 + (npos.z - pos.z)^2) - end - end - local clusters = clusterByAngle(nbrs) - for ci = 1, #clusters do - local cluster = clusters[ci] - if #cluster == 1 then - local n = cluster[1] - edgeAttach[n.edgeIdx] = edgeAttach[n.edgeIdx] or {} - edgeAttach[n.edgeIdx][n.side] = { x = pos.x, z = pos.z, hasStem = false } - else - -- Aggregate cluster geometry. Dynamic fields are computed once - -- here for the initial prov; refresh path will re-read them - -- from renderEdgesByKey via prov.members on cache-hit calls. - local avgCos, avgSin, clusterCap, minDist = 0, 0, 0, math.huge - local clusterFlow = 0 - local netFlowSigned = 0 - local effSum, capForEff = 0, 0 - local phaseSum = 0 - local stemAppear = math.huge - local stemWither = -math.huge - local allWither = true - local members = {} - for i = 1, #cluster do - local n = cluster[i] - local re = renderEdges[n.edgeIdx] - local f = re.flow or 0 - local effv = re.eff or 0 - local phasev = re.bubblePhase or 0 - avgCos = avgCos + cos(n.angle) - avgSin = avgSin + sin(n.angle) - clusterCap = clusterCap + n.cap - clusterFlow = clusterFlow + f - -- side=1 → this node is the parent (source) → flow leaves - -- side=2 → this node is the child (sink) → flow enters - netFlowSigned = netFlowSigned + ((n.side == 1) and f or -f) - effSum = effSum + effv * n.cap - phaseSum = phaseSum + phasev * n.cap - capForEff = capForEff + n.cap - if n.dist and n.dist < minDist then minDist = n.dist end - local af = re.appearFrame or 0 - if af < stemAppear then stemAppear = af end - if re.witherFrame then - if re.witherFrame > stemWither then stemWither = re.witherFrame end - else - allWither = false - end - members[#members + 1] = { key = re.key, side = n.side, cap = n.cap } - end - if stemAppear == math.huge then stemAppear = 0 end - local stemWitherFinal = (allWither and stemWither > -math.huge) and stemWither or nil - local avgAngle = atan2(avgSin, avgCos) - local stemLen = min(minDist * STEM_FRACTION, 120) - if stemLen < 4 then stemLen = 4 end - local stemX = pos.x + cos(avgAngle) * stemLen - local stemZ = pos.z + sin(avgAngle) * stemLen - local stemW = GetTrunkWidth(clusterCap) - local outward = netFlowSigned >= 0 - - local prov = { - kind = "stem", - members = members, - capForEff = capForEff, - outward = outward, - flow = clusterFlow, - eff = (capForEff > 0) and (effSum / capForEff) or 0, - bubblePhase = (capForEff > 0) and (phaseSum / capForEff) or 0, - appearFrame = stemAppear, - witherFrame = stemWitherFinal, - } - provs[#provs + 1] = prov - - if outward then - emitNoisyPath(pos.x, pos.z, stemX, stemZ, - stemW, stemW * 0.9, clusterCap, - pos.x + pos.z + ci * 7.3, false, prov) - else - emitNoisyPath(stemX, stemZ, pos.x, pos.z, - stemW * 0.9, stemW, clusterCap, - pos.x + pos.z + ci * 7.3, false, prov) - end - - for i = 1, #cluster do - local n = cluster[i] - edgeAttach[n.edgeIdx] = edgeAttach[n.edgeIdx] or {} - edgeAttach[n.edgeIdx][n.side] = { - x = stemX, z = stemZ, hasStem = true, stemW = stemW, - } - end - end - end - end - - -- Emit each edge once between its two attach points. attach[1] is the - -- parent (source) end and attach[2] is the child (sink) end, so emitting - -- attach[1] -> attach[2] makes pulses travel in the +u direction = actual - -- direction of energy flow. - for i = 1, #renderEdges do - local e = renderEdges[i] - local attach = edgeAttach[i] - if attach and attach[1] and attach[2] then - local cap = max(1, e.capacity) - local edgeW = GetTrunkWidth(cap) - local function endWidth(a) - if a.hasStem then return min(edgeW * 1.2, a.stemW * 0.55) end - return edgeW - end - local startW = endWidth(attach[1]) - local endW = endWidth(attach[2]) - local seed = attach[1].x * 0.137 + attach[1].z * 0.781 - + attach[2].x * 0.293 + attach[2].z * 0.461 - local prov = { - kind = "edge", - key = e.key, - flow = e.flow or 0, eff = e.eff or 0, - bubblePhase = e.bubblePhase or 0, - appearFrame = e.appearFrame, witherFrame = e.witherFrame, - } - provs[#provs + 1] = prov - emitNoisyPath(attach[1].x, attach[1].z, attach[2].x, attach[2].z, - startW, endW, cap, seed, false, prov) - end - end - - return allPaths, provs -end - --- Phase 2: refresh dynamic fields on cached provs from the current --- renderEdgesByKey. A cluster sign-flip (net flow direction reversed) --- invalidates the cache so the next call regenerates with the correct --- emission direction. -local function RefreshProvs(provs) - for i = 1, #provs do - local p = provs[i] - if p.kind == "edge" then - local e = renderEdgesByKey[p.key] - if e then - p.flow = e.flow or 0 - p.eff = e.eff or 0 - p.bubblePhase = e.bubblePhase or 0 - p.appearFrame = e.appearFrame - p.witherFrame = e.witherFrame - end - else - local clusterFlow = 0 - local netFlowSigned = 0 - local effSum, phaseSum = 0, 0 - local stemAppear, stemWither = math.huge, -math.huge - local allWither = true - local members = p.members - for j = 1, #members do - local m = members[j] - local e = renderEdgesByKey[m.key] - if e then - local f = e.flow or 0 - clusterFlow = clusterFlow + f - netFlowSigned = netFlowSigned + ((m.side == 1) and f or -f) - effSum = effSum + (e.eff or 0) * m.cap - phaseSum = phaseSum + (e.bubblePhase or 0) * m.cap - local af = e.appearFrame or 0 - if af < stemAppear then stemAppear = af end - if e.witherFrame then - if e.witherFrame > stemWither then stemWither = e.witherFrame end - else - allWither = false - end - end - end - if (netFlowSigned >= 0) ~= p.outward then - geomCache.valid = false - end - p.flow = clusterFlow - p.eff = (p.capForEff > 0) and (effSum / p.capForEff) or 0 - p.bubblePhase = (p.capForEff > 0) and (phaseSum / p.capForEff) or 0 - if stemAppear == math.huge then stemAppear = 0 end - p.appearFrame = stemAppear - p.witherFrame = (allWither and stemWither > -math.huge) and stemWither or nil - end - end -end - --- Phase 3: convert cached paths to triangle vertices. Reads dynamic fields --- from path.prov (refreshed per-call). All other per-vertex data is purely --- a function of (points, widths) and stays constant across cache-hit calls. -local function EmitVerts(allPaths) - local verts = {} - local vertCount = 0 - - for pi = 1, #allPaths do - local path = allPaths[pi] - local pts = path.points - local wds = path.widths - local cap = path.capacity - local branch = path.isBranch - local prov = path.prov - local appearTime = (prov.appearFrame or 0) / GAME_SPEED - local witherTime = prov.witherFrame and (prov.witherFrame / GAME_SPEED) or 0 - local pathEff = prov.eff or 0 - local pathFlow = prov.flow or 0 - local pathPhase = prov.bubblePhase or 0 - - if #pts >= 2 then - -- Averaged perpendicular at each waypoint - local perps = {} - for i = 1, #pts do - local px, pz = 0, 0 - if i > 1 then - local dx = pts[i].x - pts[i-1].x - local dz = pts[i].z - pts[i-1].z - local len = sqrt(dx*dx + dz*dz) - if len > 0.01 then px = px + (-dz/len); pz = pz + (dx/len) end - end - if i < #pts then - local dx = pts[i+1].x - pts[i].x - local dz = pts[i+1].z - pts[i].z - local len = sqrt(dx*dx + dz*dz) - if len > 0.01 then px = px + (-dz/len); pz = pz + (dx/len) end - end - local plen = sqrt(px*px + pz*pz) - if plen > 0.01 then - perps[i] = { nx = px/plen, nz = pz/plen } - else - perps[i] = { nx = 0, nz = 1 } - end - end - - -- Cumulative U distance - local uDist = { [1] = 0 } - for i = 2, #pts do - local dx = pts[i].x - pts[i-1].x - local dz = pts[i].z - pts[i-1].z - uDist[i] = uDist[i-1] + sqrt(dx*dx + dz*dz) - end - - -- Left/right vertices at each waypoint - local lefts = {} - local rights = {} - for i = 1, #pts do - local hw = (wds[i] or 5) * 0.55 - local p = perps[i] - local y = spGetGroundHeight(pts[i].x, pts[i].z) + 2 - lefts[i] = { x = pts[i].x - p.nx * hw, y = y, z = pts[i].z - p.nz * hw } - rights[i] = { x = pts[i].x + p.nx * hw, y = y, z = pts[i].z + p.nz * hw } - end - - local brVal = branch == 1 and 1 or 0 - - for i = 1, #pts - 1 do - local L1, R1, L2, R2 = lefts[i], rights[i], lefts[i+1], rights[i+1] - local u1, u2 = uDist[i], uDist[i+1] - local w1, w2 = wds[i] or 5, wds[i+1] or 5 - -- Perpendicular (cross-section direction) at each waypoint - local p1x, p1z = perps[i].nx, perps[i].nz - local p2x, p2z = perps[i+1].nx, perps[i+1].nz - - -- Tri 1: L1, R1, R2 - verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z - verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 - verts[#verts+1]=u1; verts[#verts+1]=-1 - verts[#verts+1]=p1x; verts[#verts+1]=p1z - verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase - verts[#verts+1]=R1.x; verts[#verts+1]=R1.y; verts[#verts+1]=R1.z - verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 - verts[#verts+1]=u1; verts[#verts+1]=1 - verts[#verts+1]=p1x; verts[#verts+1]=p1z - verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase - verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z - verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 - verts[#verts+1]=u2; verts[#verts+1]=1 - verts[#verts+1]=p2x; verts[#verts+1]=p2z - verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase - - -- Tri 2: L1, R2, L2 - verts[#verts+1]=L1.x; verts[#verts+1]=L1.y; verts[#verts+1]=L1.z - verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w1 - verts[#verts+1]=u1; verts[#verts+1]=-1 - verts[#verts+1]=p1x; verts[#verts+1]=p1z - verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase - verts[#verts+1]=R2.x; verts[#verts+1]=R2.y; verts[#verts+1]=R2.z - verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 - verts[#verts+1]=u2; verts[#verts+1]=1 - verts[#verts+1]=p2x; verts[#verts+1]=p2z - verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase - verts[#verts+1]=L2.x; verts[#verts+1]=L2.y; verts[#verts+1]=L2.z - verts[#verts+1]=cap; verts[#verts+1]=brVal; verts[#verts+1]=w2 - verts[#verts+1]=u2; verts[#verts+1]=-1 - verts[#verts+1]=p2x; verts[#verts+1]=p2z - verts[#verts+1]=appearTime; verts[#verts+1]=witherTime - verts[#verts+1]=pathEff; verts[#verts+1]=pathFlow; verts[#verts+1]=pathPhase - - vertCount = vertCount + 6 - end - end - end - - return verts, vertCount -end - --- Top-level entrypoint. On topology-stable rebuilds, walks the cached provs --- to refresh dynamic fields and re-emits verts using cached path geometry — --- no NoisyPath, no clustering, no twig generation. On topology change, --- rebuilds allPaths from scratch. +-- 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() - if #renderEdges == 0 then - geomCache.valid = false - geomCache.allPaths = nil - geomCache.provs = nil - return {}, 0 - end + local n = #renderEdges + if n == 0 then return {}, 0 end - if geomCache.valid then - RefreshProvs(geomCache.provs) - end - -- RefreshProvs may flip geomCache.valid off if it detected a sign change. - if not geomCache.valid then - local allPaths, provs = BuildAllPaths() - geomCache.allPaths = allPaths - geomCache.provs = provs - geomCache.valid = true - end - - return EmitVerts(geomCache.allPaths) + 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 + + -- Vertex 0: parent end + 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 + -- Vertex 1: child end (same per-edge payload) + verts[k+9] = e.cx; verts[k+10] = e.cz + verts[k+11] = cap; verts[k+12] = appearTime; verts[k+13] = witherTime + verts[k+14] = eff; verts[k+15] = flow; verts[k+16] = phase + k = k + 16 + end + return verts, n * 2 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. @@ -1575,21 +1155,65 @@ end -- cylinder normal, plus traveling energy pulses gated by LOS ($info). ------------------------------------------------------------------------------------- +-- Pass-through VS: each cable is a single GL_LINES primitive (2 vertices, +-- both carrying the same per-edge attributes). The geometry shader then +-- 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. local cableVSSrc = [[ #version 420 #extension GL_ARB_uniform_buffer_object : require #extension GL_ARB_shading_language_420pack: require -layout (location = 0) in vec3 vertPos; -layout (location = 1) in vec3 vertData; -layout (location = 2) in vec2 vertUV; -layout (location = 3) in vec2 vertPerp; -layout (location = 4) in vec2 vertTime; // x = appearTime (s), y = witherTime (s, 0 = not withering) -layout (location = 5) in vec3 vertGrid; // x = grid efficiency (E/M ratio), y = current flow (E/s), z = bubble phase at bake (elmos) +layout (location = 0) in vec2 vertPos; // (x, z) world coords +layout (location = 1) in vec3 vertData; // (capacity, appearTime, witherTime) +layout (location = 2) in vec3 vertGrid; // (gridEfficiency, flow, bubblePhase) -uniform sampler2D heightmapTex; +out gl_PerVertex { + vec4 gl_Position; +}; out DataVS { + vec2 vsWorldXZ; + vec3 vsCableData; + vec3 vsGridData; +}; + +void main() { + vsWorldXZ = vertPos; + vsCableData = vertData; + vsGridData = vertGrid; + gl_Position = vec4(0.0); +} +]] + +-- (dead-code block removed) + +-- Full GS: takes one GL_LINES primitive (the cable's two endpoints), generates +-- a wiggly path with `SEGMENTS` waypoints, samples the heightmap per waypoint +-- for terraform-correct y, and emits one triangle_strip ribbon. +-- #version 330 because that's what works alongside the engine UBO bindings +-- and the existing #version 420 VS/FS — matches outline_shader_gl4's pattern. +local cableGSSrc = [[ +#version 330 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +layout (lines) in; +// max_vertices is constrained by GL_MAX_GEOMETRY_TOTAL_OUTPUT_COMPONENTS +// (spec minimum 1024). With ~19 components emitted per vertex (gl_Position + +// DataGS payload), we have headroom for ~50 vertices total → 24-segment +// ribbon (25 boundary samples × 2 verts = 50 verts). +layout (triangle_strip, max_vertices = 50) out; + +uniform sampler2D heightmapTex; + +in DataVS { + vec2 vsWorldXZ; + vec3 vsCableData; + vec3 vsGridData; +} dataIn[]; + +out DataGS { vec3 worldPos; float capacity; float isBranch; @@ -1612,25 +1236,84 @@ float heightAtWorldPos(vec2 w) { return textureLod(heightmapTex, uvhm, 0.0).x; } +// 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 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 SEGMENTS = 24; +const float NOISE_AMP_ABS = 1.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 = 100.0; + +void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, + float w, vec3 grid, vec2 td, float cap) { + worldPos = wp; + capacity = cap; + isBranch = 0.0; + width = w; + cableUV = cuv; + perp = perpHere; + timeData = td; + gridData = grid; + gl_Position = cameraViewProj * vec4(wp, 1.0); + EmitVertex(); +} + void main() { - // Resample current ground height (so cables track terraform in real time) - vec3 pos = vertPos; - pos.y = heightAtWorldPos(vertPos.xz) + 2.0; - - worldPos = pos; - capacity = vertData.x; - isBranch = vertData.y; - width = vertData.z; - cableUV = vertUV; - perp = vertPerp; - timeData = vertTime; - gridData = vertGrid; - gl_Position = cameraViewProj * vec4(pos, 1.0); + 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; + vec3 gridD = dataIn[0].vsGridData; + + 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); + + // Stable seed derived purely from endpoint coords — same as old CPU path. + float seed = a.x * 0.137 + a.y * 0.781 + b.x * 0.293 + b.y * 0.461; + + // Wiggly ribbon: per-segment noise applied perpendicular to the straight + // line direction — same formula as the old Lua-side NoisyPath(). + float along = 0.0; + vec2 prevP = a; + for (int i = 0; i <= SEGMENTS; i++) { + float t = float(i) / float(SEGMENTS); + vec2 base = a + d * t; + float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(t); + vec2 p = base + perpAB * n; + + if (i > 0) along += distance(prevP, p); + prevP = p; + + float y = heightAtWorldPos(p) + 2.0; + vec3 leftPos = vec3(p.x - perpAB.x * halfW, y, p.y - perpAB.y * halfW); + vec3 rightPos = vec3(p.x + perpAB.x * halfW, y, p.y + perpAB.y * halfW); + + emitVtx(leftPos, perpAB, vec2(along, -1.0), widthVal, gridD, timeD, cap); + emitVtx(rightPos, perpAB, vec2(along, 1.0), widthVal, gridD, timeD, cap); + } + EndPrimitive(); } ]] local cableFSSrc = [[ -#version 330 +#version 420 #extension GL_ARB_uniform_buffer_object : require #extension GL_ARB_shading_language_420pack: require @@ -1638,7 +1321,7 @@ uniform sampler2D infoTex; uniform float gameTime; uniform float bakeTime; -in DataVS { +in DataGS { vec3 worldPos; float capacity; float isBranch; @@ -2030,7 +1713,6 @@ local function RebuildVBO() end local tGen0 = drawPerf and Spring.GetTimer() or nil - local cacheHit = geomCache.valid local verts, vertCount = GenerateOrganicTree() if vertCount == 0 then numCableVerts = 0 @@ -2041,13 +1723,13 @@ local function RebuildVBO() cableVAO = nil local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) if not vbo then return end + -- Per-vertex layout (8 floats): vertPos(2) + vertData(3) + vertGrid(3). + -- 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 = 3 }, - { id = 1, name = "vertData", size = 3 }, - { id = 2, name = "vertUV", size = 2 }, - { id = 3, name = "vertPerp", size = 2 }, - { id = 4, name = "vertTime", size = 2 }, - { id = 5, name = "vertGrid", size = 3 }, -- (efficiency, flow E/s, bubble phase elmos) + { id = 0, name = "vertPos", size = 2 }, + { id = 1, name = "vertData", size = 3 }, -- (capacity, appearTime, witherTime) + { id = 2, name = "vertGrid", size = 3 }, -- (efficiency, flow E/s, bubble phase elmos) }) local tUp0 = drawPerf and Spring.GetTimer() or nil vbo:Upload(verts) @@ -2059,12 +1741,11 @@ local function RebuildVBO() if drawPerf then local tEnd = Spring.GetTimer() Spring.Echo(string.format( - "[CableTree] draw rebuild (%s): phase=%.2f ms geom=%.2f ms upload=%.2f ms verts=%d", - cacheHit and "cache-hit" or "FULL", + "[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, vertCount / 2)) end end @@ -2113,7 +1794,9 @@ function gadget:DrawWorldPreUnit() gl.DepthMask(true) gl.Blending(false) - cableVAO:DrawArrays(GL.TRIANGLES, numCableVerts) + -- GL_LINES: every 2 verts form one cable; the geometry shader expands + -- them into a triangle_strip ribbon. + cableVAO:DrawArrays(GL.LINES, numCableVerts) cableShader:Deactivate() gl.Texture(0, false) @@ -2135,10 +1818,13 @@ function gadget:Initialize() 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, From f304938e540df26e58d247014f6d56b87576141e Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 13:04:32 +0200 Subject: [PATCH 26/59] hitch free --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 193 +++++++++++++++++----- 1 file changed, 151 insertions(+), 42 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 90a5dd4c56..5ed27b67a7 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -80,6 +80,12 @@ 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] @@ -106,6 +112,13 @@ for i = 1, #UnitDefs do 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 @@ -648,12 +661,15 @@ local function ComputeMaxPotentials() -- subDcur DOES still need per-node reads — mex draw and turret -- consumption fluctuate per tick and are not derivable from anything - -- topology-cached. + -- topology-cached. But we only call into the engine for nodes whose def + -- can possibly draw (mexes, voltage units, builders). Pure generators + -- (windmills/solar/fusion) and range-only pylons stay at 0 → at 1500+ + -- pylons this skips most of the per-tick rules-param reads. local subDcur = {} for i = 1, #order do local u = order[i] local did = nodeDefByUID[u] - subDcur[u] = did and GetNodeDcurrent(u, did) or 0 + subDcur[u] = (did and consumerByDef[did]) and GetNodeDcurrent(u, did) or 0 end for i = #order, 1, -1 do local u = order[i] @@ -1188,21 +1204,22 @@ void main() { -- (dead-code block removed) --- Full GS: takes one GL_LINES primitive (the cable's two endpoints), generates --- a wiggly path with `SEGMENTS` waypoints, samples the heightmap per waypoint --- for terraform-correct y, and emits one triangle_strip ribbon. --- #version 330 because that's what works alongside the engine UBO bindings --- and the existing #version 420 VS/FS — matches outline_shader_gl4's pattern. +-- 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. local cableGSSrc = [[ #version 330 #extension GL_ARB_uniform_buffer_object : require #extension GL_ARB_shading_language_420pack: require +#extension GL_ARB_gpu_shader5 : require -layout (lines) in; -// max_vertices is constrained by GL_MAX_GEOMETRY_TOTAL_OUTPUT_COMPONENTS -// (spec minimum 1024). With ~19 components emitted per vertex (gl_Position + -// DataGS payload), we have headroom for ~50 vertices total → 24-segment -// ribbon (25 boundary samples × 2 verts = 50 verts). +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; @@ -1240,24 +1257,37 @@ float heightAtWorldPos(vec2 w) { 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 SEGMENTS = 24; -const float NOISE_AMP_ABS = 1.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 = 100.0; +const int SEGMENTS = 24; +const float NOISE_AMP_ABS = 1.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 = 100.0; + +// Twig parameters mirror the Lua-side BRANCH_* constants. +const float BRANCH_CHANCE = 0.55; +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.5; + +float gOutBranch = 0.0; void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, float w, vec3 grid, vec2 td, float cap) { worldPos = wp; capacity = cap; - isBranch = 0.0; + isBranch = gOutBranch; width = w; cableUV = cuv; perp = perpHere; @@ -1267,29 +1297,10 @@ void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, EmitVertex(); } -void main() { - 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; - vec3 gridD = dataIn[0].vsGridData; - - 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); - - // Stable seed derived purely from endpoint coords — same as old CPU path. - float seed = a.x * 0.137 + a.y * 0.781 + b.x * 0.293 + b.y * 0.461; - - // Wiggly ribbon: per-segment noise applied perpendicular to the straight - // line direction — same formula as the old Lua-side NoisyPath(). +void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, + float halfW, float widthVal, float effAmp, float seed, + vec3 gridD, vec2 timeD, float cap) { + gOutBranch = 0.0; float along = 0.0; vec2 prevP = a; for (int i = 0; i <= SEGMENTS; i++) { @@ -1310,6 +1321,93 @@ void main() { } 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, + vec3 gridD, vec2 timeD, float cap, float tCenter, float invSeed) { + // Resolve spawn point on the wiggly main path at tCenter. + vec2 base = a + d * tCenter; + float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(tCenter); + vec2 spawn = base + perpAB * n; + + 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 & angle off the main direction. + float side = (gsHash(spawn.x, spawn.y, twigSeed + 1.0) > 0.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); + + // Build twig direction relative to the cable's straight tangent. + vec2 dirAB = d / max(length(d), 0.001); + float baseAngle = atan(dirAB.y, dirAB.x); + float angle = baseAngle + side * angleOff; + vec2 twigDir = vec2(cos(angle), sin(angle)); + vec2 twigPerp = vec2(-twigDir.y, twigDir.x); + + // Spawn at the ribbon edge so the twig pokes out of the side, not the + // midline. + vec2 root = spawn + perpAB * (halfMainW * 0.45 * side); + vec2 tip = root + twigDir * bLen; + + float twigW = max(2.0, widthVal * BRANCH_WIDTH); + float twigHWr = min(twigW, widthVal * 0.4) * WIDTH_FACTOR; + float twigHWt = twigHWr * 0.2; + + float yRoot = heightAtWorldPos(root) + 2.0; + float yTip = heightAtWorldPos(tip) + 2.0; + + vec3 rootL = vec3(root.x - twigPerp.x * twigHWr, yRoot, root.y - twigPerp.y * twigHWr); + vec3 rootR = vec3(root.x + twigPerp.x * twigHWr, yRoot, root.y + twigPerp.y * twigHWr); + vec3 tipL = vec3(tip.x - twigPerp.x * twigHWt, yTip, tip.y - twigPerp.y * twigHWt); + vec3 tipR = vec3(tip.x + twigPerp.x * twigHWt, yTip, tip.y + twigPerp.y * twigHWt); + + gOutBranch = 1.0; + emitVtx(rootL, twigPerp, vec2(0.0, -1.0), twigW, gridD, timeD, cap); + emitVtx(rootR, twigPerp, vec2(0.0, 1.0), twigW, gridD, timeD, cap); + emitVtx(tipL, twigPerp, vec2(bLen, -1.0), twigW * 0.2, gridD, timeD, cap); + emitVtx(tipR, twigPerp, vec2(bLen, 1.0), twigW * 0.2, gridD, timeD, cap); + EndPrimitive(); +} + +void main() { + 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; + vec3 gridD = dataIn[0].vsGridData; + + 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); + float seed = a.x * 0.137 + a.y * 0.781 + b.x * 0.293 + b.y * 0.461; + + if (gl_InvocationID == 0) { + emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap); + } else { + // 4 potential twigs spread across the cable interior; each invocation + // owns one slot. tCenter is biased into [0.15, 0.85] so twigs don't + // overlap the endpoint cluster regions. + int idx = gl_InvocationID - 1; // 0..3 + float tCenter = 0.15 + (float(idx) + 0.5) * (0.7 / 4.0); + emitTwig(a, d, perpAB, halfW, widthVal, effAmp, seed, + gridD, timeD, cap, tCenter, float(idx) * 13.7); + } +} ]] local cableFSSrc = [[ @@ -1627,6 +1725,7 @@ local function OnCableTreeFull() local ally = data.allyTeamID if not shouldAcceptForAlly(ally) then return end + local tStart = drawPerf and Spring.GetTimer() or nil local frame = Spring.GetGameFrame() local existing = edgesByAllyTeam[ally] or {} @@ -1689,8 +1788,18 @@ local function OnCableTreeFull() end edgesByAllyTeam[ally] = existing + local tDiff = drawPerf and Spring.GetTimer() or nil RebuildRenderEdges() needsRebuild = true + + if drawPerf 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 ------------------------------------------------------------------------------------- From 246697a3bbf5a60d4de799e46753dbd3638e829a Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 14:32:54 +0200 Subject: [PATCH 27/59] cabling --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 44 +++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 5ed27b67a7..c7b7ad01ee 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1266,8 +1266,9 @@ float gsNoiseScale(float t) { return 1.0; } -const int SEGMENTS = 24; -const float NOISE_AMP_ABS = 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 = 2.5; const float WIDTH_FACTOR = 0.55; const float MIN_TRUNK_WIDTH = 3.0; const float MAX_TRUNK_WIDTH = 12.0; @@ -1299,20 +1300,24 @@ void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, float halfW, float widthVal, float effAmp, float seed, - vec3 gridD, vec2 timeD, float cap) { + vec3 gridD, vec2 timeD, float cap, int numSeg) { 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; - vec2 prevP = a; - for (int i = 0; i <= SEGMENTS; i++) { - float t = float(i) / float(SEGMENTS); + vec3 prev3D = vec3(0.0); + for (int i = 0; i <= numSeg; i++) { + float t = float(i) / float(numSeg); vec2 base = a + d * t; float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(t); vec2 p = base + perpAB * n; - if (i > 0) along += distance(prevP, p); - prevP = p; - float y = heightAtWorldPos(p) + 2.0; + vec3 curr3D = vec3(p.x, y, p.y); + if (i > 0) along += distance(prev3D, curr3D); + prev3D = curr3D; + vec3 leftPos = vec3(p.x - perpAB.x * halfW, y, p.y - perpAB.y * halfW); vec3 rightPos = vec3(p.x + perpAB.x * halfW, y, p.y + perpAB.y * halfW); @@ -1396,8 +1401,27 @@ void main() { float effAmp = NOISE_AMP_ABS * (lenAB < 80.0 ? (lenAB / 80.0) : 1.0); float seed = a.x * 0.137 + a.y * 0.781 + b.x * 0.293 + b.y * 0.461; + // 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. + float len3D = 0.0; + { + vec3 prev3 = vec3(a.x, heightAtWorldPos(a) + 2.0, a.y); + for (int j = 1; j <= 6; j++) { + float tj = float(j) * (1.0 / 6.0); + vec2 bj = a + d * tj; + vec3 p3 = vec3(bj.x, heightAtWorldPos(bj) + 2.0, bj.y); + len3D += distance(p3, prev3); + prev3 = p3; + } + } + int numSeg = clamp(int(len3D / SEG_LEN_TARGET + 0.5), 1, MAX_SEGMENTS); + if (gl_InvocationID == 0) { - emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap); + emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg); } else { // 4 potential twigs spread across the cable interior; each invocation // owns one slot. tCenter is biased into [0.15, 0.85] so twigs don't From e96b0c3a259fcd344bc389c1a7b693e60bd3bd3d Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 15:11:20 +0200 Subject: [PATCH 28/59] improve visiblity --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 77 ++++++++++++++++------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index c7b7ad01ee..260675240f 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1313,13 +1313,21 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(t); vec2 p = base + perpAB * n; - float y = heightAtWorldPos(p) + 2.0; - vec3 curr3D = vec3(p.x, y, p.y); + // Sample heightmap independently at the two ribbon edges so the strip + // drapes across cross-slope terrain instead of clipping into the uphill + // side. `along` uses the (untwisted) centerline. + vec2 leftXZ = vec2(p.x - perpAB.x * halfW, p.y - perpAB.y * halfW); + vec2 rightXZ = vec2(p.x + perpAB.x * halfW, p.y + perpAB.y * halfW); + float yC = heightAtWorldPos(p) + 5.0; + float yL = heightAtWorldPos(leftXZ) + 5.0; + float yR = heightAtWorldPos(rightXZ) + 5.0; + + vec3 curr3D = vec3(p.x, yC, p.y); if (i > 0) along += distance(prev3D, curr3D); prev3D = curr3D; - vec3 leftPos = vec3(p.x - perpAB.x * halfW, y, p.y - perpAB.y * halfW); - vec3 rightPos = vec3(p.x + perpAB.x * halfW, y, p.y + perpAB.y * halfW); + vec3 leftPos = vec3(leftXZ.x, yL, leftXZ.y); + vec3 rightPos = vec3(rightXZ.x, yR, rightXZ.y); emitVtx(leftPos, perpAB, vec2(along, -1.0), widthVal, gridD, timeD, cap); emitVtx(rightPos, perpAB, vec2(along, 1.0), widthVal, gridD, timeD, cap); @@ -1333,7 +1341,8 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, // 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, - vec3 gridD, vec2 timeD, float cap, float tCenter, float invSeed) { + vec3 gridD, vec2 timeD, float cap, float tCenter, float invSeed, + float spawnAlongMain) { // Resolve spawn point on the wiggly main path at tCenter. vec2 base = a + d * tCenter; float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(tCenter); @@ -1366,19 +1375,24 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, float twigHWr = min(twigW, widthVal * 0.4) * WIDTH_FACTOR; float twigHWt = twigHWr * 0.2; - float yRoot = heightAtWorldPos(root) + 2.0; - float yTip = heightAtWorldPos(tip) + 2.0; + // Drape the twig along the terrain like the main ribbon. Higher clearance + // (+5) avoids the lower endpoint clipping into ground on steep slopes + // while still keeping the twig glued to the surface. + float yRoot = heightAtWorldPos(root) + 5.0; + float yTip = heightAtWorldPos(tip) + 5.0; vec3 rootL = vec3(root.x - twigPerp.x * twigHWr, yRoot, root.y - twigPerp.y * twigHWr); vec3 rootR = vec3(root.x + twigPerp.x * twigHWr, yRoot, root.y + twigPerp.y * twigHWr); vec3 tipL = vec3(tip.x - twigPerp.x * twigHWt, yTip, tip.y - twigPerp.y * twigHWt); vec3 tipR = vec3(tip.x + twigPerp.x * twigHWt, yTip, tip.y + twigPerp.y * 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. gOutBranch = 1.0; - emitVtx(rootL, twigPerp, vec2(0.0, -1.0), twigW, gridD, timeD, cap); - emitVtx(rootR, twigPerp, vec2(0.0, 1.0), twigW, gridD, timeD, cap); - emitVtx(tipL, twigPerp, vec2(bLen, -1.0), twigW * 0.2, gridD, timeD, cap); - emitVtx(tipR, twigPerp, vec2(bLen, 1.0), twigW * 0.2, gridD, timeD, cap); + emitVtx(rootL, twigPerp, vec2(spawnAlongMain, -1.0), twigW, gridD, timeD, cap); + emitVtx(rootR, twigPerp, vec2(spawnAlongMain, 1.0), twigW, gridD, timeD, cap); + emitVtx(tipL, twigPerp, vec2(spawnAlongMain + bLen, -1.0), twigW * 0.2, gridD, timeD, cap); + emitVtx(tipR, twigPerp, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.2, gridD, timeD, cap); EndPrimitive(); } @@ -1423,13 +1437,25 @@ void main() { if (gl_InvocationID == 0) { emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg); } else { - // 4 potential twigs spread across the cable interior; each invocation - // owns one slot. tCenter is biased into [0.15, 0.85] so twigs don't - // overlap the endpoint cluster regions. + // 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 - float tCenter = 0.15 + (float(idx) + 0.5) * (0.7 / 4.0); + int expectedTwigs = clamp(int(len3D / 110.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); + gridD, timeD, cap, tCenter, float(idx) * 13.7, spawnAlongMain); } } ]] @@ -1701,12 +1727,19 @@ void main() { vec3 bubbleColor = mix(gridColor, vec3(1.0), 0.15); vec3 haloColor = gridColor; // pure grid-colour halo - // Halo first (soft underglow), then body (hot core/rim), then a pure- - // white specular pop on top. Multipliers are tuned for "energy" feel — - // the halo gives bloom, the core gives plasma, the spec gives sparkle. - color += haloColor * bubbleHalo * fullLOS * 0.70; - color += bubbleColor * bubbleBody * fullLOS * 2.0; - color += vec3(1.0) * bubbleSpec * fullLOS * 1.2; + // Composition order is chosen so the bubble core never picks up the bark's + // hue: + // - Halo: additive (soft underglow that should mix with bark colour). + // - Body: max() over current colour, so the dark green/brown 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 * 0.70; + vec3 bubbleEmissive = bubbleColor * bubbleBody * fullLOS * 2.0; + color = max(color, bubbleEmissive); + color += vec3(1.0) * bubbleSpec * fullLOS * 1.2; // LOS-aware dimming float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); From 58fa910152fd2565cbaceb6d13cb178bb6ac579f Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 15:58:31 +0200 Subject: [PATCH 29/59] sort of ok --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 134 +++++++++++++++------- 1 file changed, 90 insertions(+), 44 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 260675240f..515f96fe79 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1239,6 +1239,7 @@ out DataGS { vec2 perp; vec2 timeData; vec3 gridData; + float localU; // twig-local along (0 at root, bLen at tip). Unused for main ribbon. }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1253,6 +1254,18 @@ float heightAtWorldPos(vec2 w) { 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; @@ -1268,22 +1281,24 @@ float gsNoiseScale(float t) { 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 = 2.5; +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 = 100.0; // Twig parameters mirror the Lua-side BRANCH_* constants. -const float BRANCH_CHANCE = 0.55; +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.5; +const float BRANCH_WIDTH = 0.85; float gOutBranch = 0.0; +float gOutLocalU = 0.0; // set per-vertex by twig emitters; main ribbon leaves at 0. + void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, float w, vec3 grid, vec2 td, float cap) { worldPos = wp; @@ -1294,6 +1309,7 @@ void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, perp = perpHere; timeData = td; gridData = grid; + localU = gOutLocalU; gl_Position = cameraViewProj * vec4(wp, 1.0); EmitVertex(); } @@ -1359,40 +1375,57 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, float bLen = BRANCH_LEN_MIN + gsHashU(spawn.x, spawn.y, twigSeed + 3.0) * (BRANCH_LEN_MAX - BRANCH_LEN_MIN); - // Build twig direction relative to the cable's straight tangent. - vec2 dirAB = d / max(length(d), 0.001); - float baseAngle = atan(dirAB.y, dirAB.x); - float angle = baseAngle + side * angleOff; - vec2 twigDir = vec2(cos(angle), sin(angle)); - vec2 twigPerp = vec2(-twigDir.y, twigDir.x); - - // Spawn at the ribbon edge so the twig pokes out of the side, not the - // midline. - vec2 root = spawn + perpAB * (halfMainW * 0.45 * side); - vec2 tip = root + twigDir * bLen; - - float twigW = max(2.0, widthVal * BRANCH_WIDTH); - float twigHWr = min(twigW, widthVal * 0.4) * WIDTH_FACTOR; - float twigHWt = twigHWr * 0.2; - - // Drape the twig along the terrain like the main ribbon. Higher clearance - // (+5) avoids the lower endpoint clipping into ground on steep slopes - // while still keeping the twig glued to the surface. - float yRoot = heightAtWorldPos(root) + 5.0; - float yTip = heightAtWorldPos(tip) + 5.0; - - vec3 rootL = vec3(root.x - twigPerp.x * twigHWr, yRoot, root.y - twigPerp.y * twigHWr); - vec3 rootR = vec3(root.x + twigPerp.x * twigHWr, yRoot, root.y + twigPerp.y * twigHWr); - vec3 tipL = vec3(tip.x - twigPerp.x * twigHWt, yTip, tip.y - twigPerp.y * twigHWt); - vec3 tipR = vec3(tip.x + twigPerp.x * twigHWt, yTip, tip.y + twigPerp.y * twigHWt); + float twigW = max(2.5, widthVal * BRANCH_WIDTH); + float twigHWr = min(twigW, widthVal * 0.55) * WIDTH_FACTOR; + float twigHWt = twigHWr * 0.25; + + // 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)); + + float clearance = 5.0; + vec3 spawn3D = vec3(spawn.x, heightAtWorldPos(spawn), spawn.y) + N * clearance; + + // 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; + + // Horizontal projection of twigPerp for the FS varying (the FS reconstructs + // the cable normal via screen-space derivatives + this horizontal hint). + vec2 twigPerpH = vec2(twigPerp3D.x, twigPerp3D.z); + float lh = length(twigPerpH); + if (lh > 1e-4) twigPerpH /= lh; else twigPerpH = vec2(1.0, 0.0); // cableUV.x carries the cable-wide along distance so the FS growth gate // hides this twig until the main growth front has reached spawnAlongMain. + // localU is twig-local along (0..bLen) — the FS uses it for the synced + // single-bubble animation in twigs (independent of cable-global phase). gOutBranch = 1.0; - emitVtx(rootL, twigPerp, vec2(spawnAlongMain, -1.0), twigW, gridD, timeD, cap); - emitVtx(rootR, twigPerp, vec2(spawnAlongMain, 1.0), twigW, gridD, timeD, cap); - emitVtx(tipL, twigPerp, vec2(spawnAlongMain + bLen, -1.0), twigW * 0.2, gridD, timeD, cap); - emitVtx(tipR, twigPerp, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.2, gridD, timeD, cap); + gOutLocalU = 0.0; + emitVtx(rootL, twigPerpH, vec2(spawnAlongMain, -1.0), twigW, gridD, timeD, cap); + emitVtx(rootR, twigPerpH, vec2(spawnAlongMain, 1.0), twigW, gridD, timeD, cap); + gOutLocalU = bLen; + emitVtx(tipL, twigPerpH, vec2(spawnAlongMain + bLen, -1.0), twigW * 0.25, gridD, timeD, cap); + emitVtx(tipR, twigPerpH, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.25, gridD, timeD, cap); EndPrimitive(); } @@ -1442,7 +1475,7 @@ void main() { // 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 / 110.0 + 0.5), 0, 4); + 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 @@ -1478,6 +1511,7 @@ in DataGS { vec2 perp; vec2 timeData; // x = appearTime, y = witherTime (0 = not withering) vec3 gridData; // x = efficiency (E/M), y = flow (E/s), z = bubble phase at bake (elmos) + float localU; // twig-local along (0 at root, bLen at tip). Unused for main ribbon. }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1711,16 +1745,28 @@ void main() { float phase = gridData.z + speed * (gameTime - bakeTime); float halfWidthE = width * 0.5; // cable cross half-extent in elmos - // Two layers of mixed-size bubbles. Density is fixed (constant spacing); - // per-bubble radius jitter inside each layer gives the small/big mix. - // Each layer returns (body, spec, halo); we composite them with - // different colour weights so the bubble reads as glowing plasma. - vec3 bA = bubbleLayer(along, phase, 75.0, 7.5, v, halfWidthE, 3.7); - vec3 bB = bubbleLayer(along, phase, 32.0, 4.0, v, halfWidthE, 19.1); - - float bubbleBody = bA.x + bB.x * 0.85; - float bubbleSpec = bA.y + bB.y * 0.85; - float bubbleHalo = bA.z + bB.z * 0.55; + // Bubble pass: main ribbon uses two layers of advecting bubbles. Twigs + // instead show ONE synced bubble traversing twig-local space at the main + // cable's speed, with period = TWIG_SPACING/speed. Using gameTime*speed as + // the global phase makes every twig in a cable pulse in lockstep — that's + // the "bug-as-feature" Licho asked to bring back. + float bubbleBody, bubbleSpec, bubbleHalo; + if (isBranch > 0.5) { + const float TWIG_SPACING = 75.0; + float twigPhase = mod(gameTime * speed, TWIG_SPACING); + // Single-bubble layer: spacing=TWIG_SPACING, radius slightly bigger so + // the pulse reads clearly on short twigs. + vec3 bT = bubbleLayer(localU, twigPhase, TWIG_SPACING, 5.0, v, halfWidthE, 0.0); + bubbleBody = bT.x; + bubbleSpec = bT.y; + bubbleHalo = bT.z; + } else { + vec3 bA = bubbleLayer(along, phase, 75.0, 7.5, v, halfWidthE, 3.7); + vec3 bB = bubbleLayer(along, phase, 32.0, 4.0, v, halfWidthE, 19.1); + bubbleBody = bA.x + bB.x * 0.85; + bubbleSpec = bA.y + bB.y * 0.85; + bubbleHalo = bA.z + bB.z * 0.55; + } // Bubble colour: keep the grid efficiency hue (low dilution → punchier). vec3 gridColor = gridEfficiencyColor(gridData.x); From 9c87114933d22b7be6bc338d2cbad2cc7ff00f64 Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 16:38:05 +0200 Subject: [PATCH 30/59] more natural flow gestalt --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 101 +++++++++++++++------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 515f96fe79..f79492fe12 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1025,13 +1025,36 @@ local GAME_SPEED = Game.gameSpeed or 30 -- 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. -local BUBBLE_FLOW_REF = 80 -local BUBBLE_MAX_SPEED = 220 -local function flowToSpeed(flow) - if not flow or flow < 0 then return 0 end - local n = flow / BUBBLE_FLOW_REF - if n > 1.6 then n = 1.6 end - return BUBBLE_MAX_SPEED * n +-- +-- 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 = 100.0 + +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 ------------------------------------------------------------------------------------- @@ -1731,38 +1754,53 @@ void main() { // - Three layered streams of bubbles (big, medium, 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 mapping must match the CPU's flowToSpeed (otherwise the - // CPU-integrated phase and shader-extrapolated phase disagree and we get - // the very jumps this anchor scheme exists to eliminate). - const float FLOW_REF = 80.0; - const float MAX_SPEED = 220.0; + // 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. + const float MAX_SPEED = 110.0; + const float FLOW_REF = 50.0; + const float MIN_TRUNK_W = 3.0; float flow = gridData.y; - float speed = MAX_SPEED * clamp(flow / FLOW_REF, 0.0, 1.6); + // 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); - float halfWidthE = width * 0.5; // cable cross half-extent in elmos - // Bubble pass: main ribbon uses two layers of advecting bubbles. Twigs - // instead show ONE synced bubble traversing twig-local space at the main - // cable's speed, with period = TWIG_SPACING/speed. Using gameTime*speed as - // the global phase makes every twig in a cable pulse in lockstep — that's - // the "bug-as-feature" Licho asked to bring back. + // 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 = 105.0 / spacingMul; + float spacingB = 48.0 / spacingMul; + + // Bubble pass: main ribbon uses two layers of advecting bubbles whose + // spacing is modulated by densityFactor. Twigs instead show synced bubbles + // at the same big-bubble rhythm so every twig in a cable pulses in lockstep + // at the main cable's speed. float bubbleBody, bubbleSpec, bubbleHalo; if (isBranch > 0.5) { - const float TWIG_SPACING = 75.0; - float twigPhase = mod(gameTime * speed, TWIG_SPACING); - // Single-bubble layer: spacing=TWIG_SPACING, radius slightly bigger so - // the pulse reads clearly on short twigs. - vec3 bT = bubbleLayer(localU, twigPhase, TWIG_SPACING, 5.0, v, halfWidthE, 0.0); + float twigPhase = mod(gameTime * speed, spacingA); + vec3 bT = bubbleLayer(localU, twigPhase, spacingA, 5.0, v, halfWidthE, 0.0); bubbleBody = bT.x; bubbleSpec = bT.y; bubbleHalo = bT.z; } else { - vec3 bA = bubbleLayer(along, phase, 75.0, 7.5, v, halfWidthE, 3.7); - vec3 bB = bubbleLayer(along, phase, 32.0, 4.0, v, halfWidthE, 19.1); + vec3 bA = bubbleLayer(along, phase, spacingA, 7.5, v, halfWidthE, 3.7); + vec3 bB = bubbleLayer(along, phase, spacingB, 4.0, v, halfWidthE, 19.1); bubbleBody = bA.x + bB.x * 0.85; bubbleSpec = bA.y + bB.y * 0.85; bubbleHalo = bA.z + bB.z * 0.55; @@ -1862,7 +1900,7 @@ local function OnCableTreeFull() local oldAnchor = e.bubbleAnchorTime or nowSec e.bubblePhase = (e.bubblePhase or 0) + oldSpeed * (nowSec - oldAnchor) e.bubbleAnchorTime = nowSec - e.bubbleSpeed = flowToSpeed(newFlow) + e.bubbleSpeed = flowToSpeed(newFlow, data.caps[i]) e.capacity = data.caps[i] e.flow = newFlow @@ -1884,7 +1922,7 @@ local function OnCableTreeFull() -- shader can extrapolate forward from this anchor. bubblePhase = 0, bubbleAnchorTime = nowSec, - bubbleSpeed = flowToSpeed(newFlow), + bubbleSpeed = flowToSpeed(newFlow, data.caps[i]), } geomCache.valid = false -- topology change → full geometry rebuild end @@ -1987,7 +2025,12 @@ function gadget:GameFrame(n) geomCache.valid = false end - if needsRebuild and n % 6 == 0 then + -- 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 end From b03f66e5f388796676ef01c766644fe835d2e8f9 Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 20:04:19 +0200 Subject: [PATCH 31/59] desaturate --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 58 ++++++++++++++++------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index f79492fe12..16f9ae5345 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -68,7 +68,7 @@ local DEBUG_FLOW = false -- echo per-edge capacity table on every Send (c -- 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 = "euclidean" +local MST_MODE = "realistic" ------------------------------------------------------------------------------------- -- Unit definitions @@ -1381,7 +1381,7 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, void emitTwig(vec2 a, vec2 d, vec2 perpAB, float halfMainW, float widthVal, float effAmp, float seed, vec3 gridD, vec2 timeD, float cap, float tCenter, float invSeed, - float spawnAlongMain) { + float spawnAlongMain, int twigIdx) { // Resolve spawn point on the wiggly main path at tCenter. vec2 base = a + d * tCenter; float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(tCenter); @@ -1391,8 +1391,11 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, float chance = gsHashU(spawn.x, spawn.y, twigSeed); if (chance > BRANCH_CHANCE) return; - // Side & angle off the main direction. - float side = (gsHash(spawn.x, spawn.y, twigSeed + 1.0) > 0.0) ? 1.0 : -1.0; + // 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 + @@ -1511,7 +1514,7 @@ void main() { / float(numSeg); float spawnAlongMain = len3D * tCenter; emitTwig(a, d, perpAB, halfW, widthVal, effAmp, seed, - gridD, timeD, cap, tCenter, float(idx) * 13.7, spawnAlongMain); + gridD, timeD, cap, tCenter, float(idx) * 13.7, spawnAlongMain, idx); } } ]] @@ -1719,10 +1722,11 @@ void main() { vec3 halfDir = normalize(normalize(sunDir.xyz) + viewDir); float spec = pow(max(0.0, dot(cylNormal, halfDir)), 24.0) * 0.35; - // Capacity-based color (green glow) + // Light-gray cable test: bark and inner are neutral grays, lit only by + // diffuse + bubble glow. Industrial conduit look. float capT = clamp(capacity / 100.0, 0.0, 1.0); - vec3 barkColor = vec3(0.06, 0.04, 0.02); - vec3 innerColor = mix(vec3(0.20, 0.55, 0.15), vec3(0.50, 0.80, 0.20), capT); + vec3 barkColor = vec3(0.55, 0.55, 0.55); + vec3 innerColor = mix(vec3(0.65, 0.65, 0.65), vec3(0.85, 0.85, 0.85), capT); float innerMix = smoothstep(0.85, 0.15, t); if (isBranch > 0.5) innerMix *= 0.7; @@ -1793,11 +1797,26 @@ void main() { // at the main cable's speed. float bubbleBody, bubbleSpec, bubbleHalo; if (isBranch > 0.5) { - float twigPhase = mod(gameTime * speed, spacingA); - vec3 bT = bubbleLayer(localU, twigPhase, spacingA, 5.0, v, halfWidthE, 0.0); - bubbleBody = bT.x; - bubbleSpec = bT.y; - bubbleHalo = bT.z; + // Synced uniform "power spark": the whole twig flashes bright at once, + // like an electrical discharge. NO spatial position dependence — every + // pixel of the twig has identical brightness — so the eye cannot + // perceive any directional motion (and therefore cannot misperceive + // reflection / back-and-forth). All twigs of a cable share `phase`, + // so they spark together. Frequency = speed / PERIOD (linear in flow). + // + // Envelope: very fast rise (~3% of period), then exponential decay. + // Reads as a sharp "zap" of energy along the conduit. + const float PERIOD = 220.0; + float pulse = mod(phase, PERIOD) / PERIOD; // [0,1) + float rise = smoothstep(0.0, 0.03, pulse); + float decay = exp(-pulse * 9.0); // dies before next cycle + float spark = rise * decay; + float v2 = v * v; + float cross = 1.0 - smoothstep(0.7, 1.0, v2); + spark *= cross; + bubbleBody = spark * 1.30; + bubbleSpec = spark * 0.65; + bubbleHalo = spark * 0.55; } else { vec3 bA = bubbleLayer(along, phase, spacingA, 7.5, v, halfWidthE, 3.7); vec3 bB = bubbleLayer(along, phase, spacingB, 4.0, v, halfWidthE, 19.1); @@ -1806,10 +1825,13 @@ void main() { bubbleHalo = bA.z + bB.z * 0.55; } - // Bubble colour: keep the grid efficiency hue (low dilution → punchier). + // Bubble colour: grid-efficiency hue, lightly toned down so it still + // glows clearly but isn't neon-saturated. vec3 gridColor = gridEfficiencyColor(gridData.x); - vec3 bubbleColor = mix(gridColor, vec3(1.0), 0.15); - vec3 haloColor = gridColor; // pure grid-colour halo + float gridLum = dot(gridColor, vec3(0.299, 0.587, 0.114)); + vec3 grayedGrid = mix(gridColor, vec3(gridLum), 0.18); + vec3 bubbleColor = mix(grayedGrid, vec3(1.0), 0.15); + vec3 haloColor = grayedGrid; // Composition order is chosen so the bubble core never picks up the bark's // hue: @@ -1821,9 +1843,9 @@ void main() { // emissive plasma show its real colour through the cable in shadow. // - Spec: additive white sparkle on top. color += haloColor * bubbleHalo * fullLOS * 0.70; - vec3 bubbleEmissive = bubbleColor * bubbleBody * fullLOS * 2.0; + vec3 bubbleEmissive = bubbleColor * bubbleBody * fullLOS * 1.85; color = max(color, bubbleEmissive); - color += vec3(1.0) * bubbleSpec * fullLOS * 1.2; + color += vec3(1.0) * bubbleSpec * fullLOS * 1.10; // LOS-aware dimming float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); From ddea83e89aa95fd03ce4687da405487539ba8fea Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 20:58:20 +0200 Subject: [PATCH 32/59] pathing arcs --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 257 ++++++++++++++++------ 1 file changed, 193 insertions(+), 64 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 16f9ae5345..da4d14c1fd 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1169,16 +1169,17 @@ local function GenerateOrganicTree() 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 - -- Vertex 0: parent end + -- Vertex 0: parent end (9 floats: pos2 + data3 + grid4) 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+6] = eff; verts[k+7] = flow; verts[k+8] = phase; verts[k+9] = isOwn -- Vertex 1: child end (same per-edge payload) - verts[k+9] = e.cx; verts[k+10] = e.cz - verts[k+11] = cap; verts[k+12] = appearTime; verts[k+13] = witherTime - verts[k+14] = eff; verts[k+15] = flow; verts[k+16] = phase - k = k + 16 + verts[k+10] = e.cx; verts[k+11] = e.cz + verts[k+12] = cap; verts[k+13] = appearTime; verts[k+14] = witherTime + verts[k+15] = eff; verts[k+16] = flow; verts[k+17] = phase; verts[k+18] = isOwn + k = k + 18 end return verts, n * 2 end @@ -1205,7 +1206,7 @@ local cableVSSrc = [[ layout (location = 0) in vec2 vertPos; // (x, z) world coords layout (location = 1) in vec3 vertData; // (capacity, appearTime, witherTime) -layout (location = 2) in vec3 vertGrid; // (gridEfficiency, flow, bubblePhase) +layout (location = 2) in vec4 vertGrid; // (gridEfficiency, flow, bubblePhase, isOwnAlly) out gl_PerVertex { vec4 gl_Position; @@ -1214,7 +1215,7 @@ out gl_PerVertex { out DataVS { vec2 vsWorldXZ; vec3 vsCableData; - vec3 vsGridData; + vec4 vsGridData; }; void main() { @@ -1250,7 +1251,7 @@ uniform sampler2D heightmapTex; in DataVS { vec2 vsWorldXZ; vec3 vsCableData; - vec3 vsGridData; + vec4 vsGridData; } dataIn[]; out DataGS { @@ -1261,7 +1262,7 @@ out DataGS { vec2 cableUV; vec2 perp; vec2 timeData; - vec3 gridData; + vec4 gridData; float localU; // twig-local along (0 at root, bLen at tip). Unused for main ribbon. }; @@ -1323,7 +1324,7 @@ float gOutBranch = 0.0; float gOutLocalU = 0.0; // set per-vertex by twig emitters; main ribbon leaves at 0. void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, - float w, vec3 grid, vec2 td, float cap) { + float w, vec4 grid, vec2 td, float cap) { worldPos = wp; capacity = cap; isBranch = gOutBranch; @@ -1337,36 +1338,120 @@ void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, 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; +} + void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, float halfW, float widthVal, float effAmp, float seed, - vec3 gridD, vec2 timeD, float cap, int numSeg) { + 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); for (int i = 0; i <= numSeg; i++) { float t = float(i) / float(numSeg); - vec2 base = a + d * t; + 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 p = base + perpAB * n; - // Sample heightmap independently at the two ribbon edges so the strip - // drapes across cross-slope terrain instead of clipping into the uphill - // side. `along` uses the (untwisted) centerline. - vec2 leftXZ = vec2(p.x - perpAB.x * halfW, p.y - perpAB.y * halfW); - vec2 rightXZ = vec2(p.x + perpAB.x * halfW, p.y + perpAB.y * halfW); - float yC = heightAtWorldPos(p) + 5.0; - float yL = heightAtWorldPos(leftXZ) + 5.0; - float yR = heightAtWorldPos(rightXZ) + 5.0; - - vec3 curr3D = vec3(p.x, yC, p.y); - if (i > 0) along += distance(prev3D, curr3D); - prev3D = curr3D; - - vec3 leftPos = vec3(leftXZ.x, yL, leftXZ.y); - vec3 rightPos = vec3(rightXZ.x, yR, rightXZ.y); + // Build the cable's local cross-section in the slope's tangent plane at + // `p` so the visible 3D L→R distance is exactly `widthVal` regardless of + // terrain tilt. The previous version offset L/R purely in horizontal XZ + // then sampled height independently, which made the visible cross-section + // inflate on cross-slopes (sqrt((2*halfW)^2 + (yL-yR)^2) > 2*halfW). + // Now: N = terrain normal at p; T = horizontal cable tangent projected + // into the slope plane; B = N × T. L = center3D + B*halfW (with B's sign + // chosen to land on `-perpAB`), R = center3D - B*halfW. + float yC = heightAtWorldPos(p) + 6.0; + vec3 center3D = vec3(p.x, yC, p.y); + + vec3 N = terrainNormal(p); + vec3 cableDirH = normalize(vec3(d.x, 0.0, d.y)); + vec3 T3 = normalize(cableDirH - dot(cableDirH, N) * N); + vec3 B3 = normalize(cross(N, T3)); + // Align B3 with -perpAB so cableUV.y signs match the prior convention + // (left vertex carries cableUV.y = -1). + vec3 perpRefH = normalize(vec3(-perpAB.x, 0.0, -perpAB.y)); + if (dot(B3, perpRefH) < 0.0) B3 = -B3; + + 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 + + // minClearance whenever that happens. On linear terrain the L/R points + // already sit at clearance above ground so this is a no-op there. + float minSideClearance = 3.0; + float hL_xz = heightAtWorldPos(leftPos.xz) + minSideClearance; + float hR_xz = heightAtWorldPos(rightPos.xz) + minSideClearance; + leftPos.y = max(leftPos.y, hL_xz); + rightPos.y = max(rightPos.y, hR_xz); + + // 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; + + if (i > 0) along += distance(prev3D, center3D); + prev3D = center3D; emitVtx(leftPos, perpAB, vec2(along, -1.0), widthVal, gridD, timeD, cap); emitVtx(rightPos, perpAB, vec2(along, 1.0), widthVal, gridD, timeD, cap); @@ -1380,10 +1465,11 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, // 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, - vec3 gridD, vec2 timeD, float cap, float tCenter, float invSeed, - float spawnAlongMain, int twigIdx) { - // Resolve spawn point on the wiggly main path at tCenter. - vec2 base = a + d * tCenter; + vec4 gridD, vec2 timeD, float cap, float tCenter, float invSeed, + float spawnAlongMain, int twigIdx, float arcDh) { + // Resolve spawn point on the wiggly main path at tCenter. Apply the same + // arc bias as the main ribbon so twigs root on the visible cable. + vec2 base = arcBiasedCenter(a, d, perpAB, tCenter, length(d), arcDh); float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(tCenter); vec2 spawn = base + perpAB * n; @@ -1466,7 +1552,7 @@ void main() { float cap = dataIn[0].vsCableData.x; vec2 timeD = dataIn[0].vsCableData.yz; - vec3 gridD = dataIn[0].vsGridData; + vec4 gridD = dataIn[0].vsGridData; float widthVal = MIN_TRUNK_WIDTH + clamp(cap / MAX_CAPACITY_REF, 0.0, 1.0) * (MAX_TRUNK_WIDTH - MIN_TRUNK_WIDTH); @@ -1480,21 +1566,41 @@ void main() { // 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; { - vec3 prev3 = vec3(a.x, heightAtWorldPos(a) + 2.0, a.y); + 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; - vec3 p3 = vec3(bj.x, heightAtWorldPos(bj) + 2.0, bj.y); + 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); // sum |Δslope| as curvature proxy + prevDy = dy; prev3 = p3; } } - int numSeg = clamp(int(len3D / SEG_LEN_TARGET + 0.5), 1, MAX_SEGMENTS); + // 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. + float arcDh = cableArcDh(a, d, perpAB, lenAB); if (gl_InvocationID == 0) { - emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg); + emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg, arcDh); } else { // 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. @@ -1514,7 +1620,7 @@ void main() { / float(numSeg); float spawnAlongMain = len3D * tCenter; emitTwig(a, d, perpAB, halfW, widthVal, effAmp, seed, - gridD, timeD, cap, tCenter, float(idx) * 13.7, spawnAlongMain, idx); + gridD, timeD, cap, tCenter, float(idx) * 13.7, spawnAlongMain, idx, arcDh); } } ]] @@ -1536,7 +1642,7 @@ in DataGS { vec2 cableUV; vec2 perp; vec2 timeData; // x = appearTime, y = witherTime (0 = not withering) - vec3 gridData; // x = efficiency (E/M), y = flow (E/s), z = bubble phase at bake (elmos) + vec4 gridData; // x = efficiency (E/M), y = flow (E/s), z = bubble phase at bake (elmos), w = isOwnAlly (1 own, 0 enemy) float localU; // twig-local along (0 at root, bLen at tip). Unused for main ribbon. }; @@ -1742,6 +1848,11 @@ void main() { float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); float fullLOS = smoothstep(0.7, 1.0, losState); + // Enemy cables: only show in actual LOS (no ghost / no out-of-LOS render). + // Own cables show even out-of-LOS, dimmed (existing dimFactor handles it). + float isOwnAlly = gridData.w; + if (isOwnAlly < 0.5 && losState < 0.45) discard; + // Apply lighting vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.85) * spec; @@ -1797,26 +1908,38 @@ void main() { // at the main cable's speed. float bubbleBody, bubbleSpec, bubbleHalo; if (isBranch > 0.5) { - // Synced uniform "power spark": the whole twig flashes bright at once, - // like an electrical discharge. NO spatial position dependence — every - // pixel of the twig has identical brightness — so the eye cannot - // perceive any directional motion (and therefore cannot misperceive - // reflection / back-and-forth). All twigs of a cable share `phase`, - // so they spark together. Frequency = speed / PERIOD (linear in flow). + // "Electric crackle" — synchronized power buzzing on twigs. + // + // CRITICAL constraint: every spatial reference inside this branch must + // be cross-cable only (`v`), never along-twig. Any along-twig gradient + // keyed off a temporally advancing scalar can be read as motion, and a + // motion that wraps around at phase reset reads as "backwards bouncing". + // We use ONLY `phase` (time-like, per-cable, shared across all twigs) and + // `v` (cross-cable shape). No `localU`, no `along`, no `worldPos`-along. + // + // Two stacked uniform-in-space components, both functions of phase only: + // * crackle: high-frequency hash-driven flicker, ticks per ~5 elmos of + // phase. Reads as electric arcing in the conduit. Brightness is a + // random scalar per tick, identical across the entire twig surface. + // * zap: occasional bigger Gaussian-shaped pulse, ~1 per 220 elmos + // of phase. Reads as a "power surge" hitting the twigs. // - // Envelope: very fast rise (~3% of period), then exponential decay. - // Reads as a sharp "zap" of energy along the conduit. - const float PERIOD = 220.0; - float pulse = mod(phase, PERIOD) / PERIOD; // [0,1) - float rise = smoothstep(0.0, 0.03, pulse); - float decay = exp(-pulse * 9.0); // dies before next cycle - float spark = rise * decay; - float v2 = v * v; - float cross = 1.0 - smoothstep(0.7, 1.0, v2); - spark *= cross; - bubbleBody = spark * 1.30; - bubbleSpec = spark * 0.65; - bubbleHalo = spark * 0.55; + // All twigs of a cable share `phase`, so they crackle / zap in lockstep. + // Phase advance rate = speed, so faster grids buzz / zap faster. + const float TICK = 5.0; // elmos of phase per crackle tick + const float ZAP_PER = 220.0; // elmos of phase per power zap + float tickIdx = floor(phase / TICK); + float h0 = hash1(tickIdx); + float h1 = hash1(tickIdx + 17.3); + float crackle = h0 * h0 * step(0.55, h1); // sparse + biased dim + float zapPhase = mod(phase, ZAP_PER) / ZAP_PER; + float zd = zapPhase - 0.08; // peak shortly after wrap + float zap = exp(-zd * zd * 90.0); // Gaussian, sharp + float cross = 1.0 - smoothstep(0.6, 1.0, v * v); + float intensity = (crackle * 0.55 + zap * 1.10) * cross; + bubbleBody = intensity * 1.20; + bubbleSpec = intensity * 0.70; + bubbleHalo = intensity * 0.50; } else { vec3 bA = bubbleLayer(along, phase, spacingA, 7.5, v, halfWidthE, 3.7); vec3 bB = bubbleLayer(along, phase, spacingB, 4.0, v, halfWidthE, 19.1); @@ -1860,11 +1983,13 @@ void main() { -- Receive data from synced ------------------------------------------------------------------------------------- -local function shouldAcceptForAlly(allyTeamID) +-- 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() - local myAllyTeam = spGetMyAllyTeamID() if (spec or fullview) then return true end - return allyTeamID == myAllyTeam + return allyTeamID == spGetMyAllyTeamID() end local function RebuildRenderEdges() @@ -1886,7 +2011,9 @@ local function OnCableTreeFull() local data = SYNCED.CableTreeFull if not data then return end local ally = data.allyTeamID - if not shouldAcceptForAlly(ally) then return end + -- 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 = drawPerf and Spring.GetTimer() or nil local frame = Spring.GetGameFrame() @@ -1927,6 +2054,7 @@ local function OnCableTreeFull() e.capacity = data.caps[i] e.flow = newFlow e.eff = data.effs and data.effs[i] or 0 + e.isOwnAlly = ownAlly -- positions are stable for unchanged edges; assign anyway in case parent moved e.px, e.pz = data.pxs[i], data.pzs[i] e.cx, e.cz = data.cxs[i], data.czs[i] @@ -1937,6 +2065,7 @@ local function OnCableTreeFull() capacity = data.caps[i], flow = newFlow, eff = data.effs and data.effs[i] or 0, + isOwnAlly = ownAlly, appearFrame = frame, witherFrame = nil, key = k, @@ -1995,13 +2124,13 @@ local function RebuildVBO() cableVAO = nil local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) if not vbo then return end - -- Per-vertex layout (8 floats): vertPos(2) + vertData(3) + vertGrid(3). + -- Per-vertex layout (9 floats): vertPos(2) + vertData(3) + vertGrid(4). -- 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 = 3 }, -- (efficiency, flow E/s, bubble phase elmos) + { id = 2, name = "vertGrid", size = 4 }, -- (efficiency, flow E/s, bubble phase elmos, isOwnAlly) }) local tUp0 = drawPerf and Spring.GetTimer() or nil vbo:Upload(verts) From b508d7729f14028edf16e565a9e8c99a1974d72d Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 22:33:48 +0200 Subject: [PATCH 33/59] move normals to FS --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 116 ++++++++++++++-------- 1 file changed, 77 insertions(+), 39 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index da4d14c1fd..5d7cd527ad 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1260,10 +1260,10 @@ out DataGS { float isBranch; float width; vec2 cableUV; - vec2 perp; + vec2 perp; // horizontal (XZ) projection of slope-aligned cross direction vec2 timeData; vec4 gridData; - float localU; // twig-local along (0 at root, bLen at tip). Unused for main ribbon. + float localU; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1323,14 +1323,20 @@ float gOutBranch = 0.0; float gOutLocalU = 0.0; // set per-vertex by twig emitters; main ribbon leaves at 0. -void emitVtx(vec3 wp, vec2 perpHere, vec2 cuv, +void emitVtx(vec3 wp, vec3 perpHere, vec2 cuv, float w, vec4 grid, vec2 td, float cap) { worldPos = wp; capacity = cap; isBranch = gOutBranch; width = w; cableUV = cuv; - perp = perpHere; + // We bake only the *horizontal* projection of the slope-aligned cross + // direction. The FS reconstructs the full 3D direction by rotating it + // into the local surface tangent plane (using screen-space derivatives + // of worldPos), so we don't need to spend a fresh varying on the Y + // component — the GS-output varying budget on this hardware is tight + // and adding any extra component pushes the linker over. + perp = perpHere.xz; timeData = td; gridData = grid; localU = gOutLocalU; @@ -1419,7 +1425,7 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, // Now: N = terrain normal at p; T = horizontal cable tangent projected // into the slope plane; B = N × T. L = center3D + B*halfW (with B's sign // chosen to land on `-perpAB`), R = center3D - B*halfW. - float yC = heightAtWorldPos(p) + 6.0; + float yC = heightAtWorldPos(p) + 3.0; vec3 center3D = vec3(p.x, yC, p.y); vec3 N = terrainNormal(p); @@ -1439,7 +1445,7 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, // below the actual heightmap at their XZ. Raise to local terrain + // minClearance whenever that happens. On linear terrain the L/R points // already sit at clearance above ground so this is a no-op there. - float minSideClearance = 3.0; + float minSideClearance = 1.5; float hL_xz = heightAtWorldPos(leftPos.xz) + minSideClearance; float hR_xz = heightAtWorldPos(rightPos.xz) + minSideClearance; leftPos.y = max(leftPos.y, hL_xz); @@ -1453,8 +1459,11 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, if (i > 0) along += distance(prev3D, center3D); prev3D = center3D; - emitVtx(leftPos, perpAB, vec2(along, -1.0), widthVal, gridD, timeD, cap); - emitVtx(rightPos, perpAB, vec2(along, 1.0), widthVal, gridD, timeD, cap); + // Pass B3 (slope-aligned cross direction) as the perp varying — the FS + // reconstructs the cylinder cross-coordinate from this. Using horizontal + // perpAB here previously caused a corkscrew/twist on cross-slopes. + emitVtx(leftPos, B3, vec2(along, -1.0), widthVal, gridD, timeD, cap); + emitVtx(rightPos, B3, vec2(along, 1.0), widthVal, gridD, timeD, cap); } EndPrimitive(); } @@ -1508,7 +1517,7 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, vec3 twigDir3D = ca * T + sa * B; vec3 twigPerp3D = normalize(cross(N, twigDir3D)); - float clearance = 5.0; + float clearance = 2.5; vec3 spawn3D = vec3(spawn.x, heightAtWorldPos(spawn), spawn.y) + N * clearance; // Anchor the root to the spawn-side edge of the cable's in-slope cross @@ -1521,23 +1530,19 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, vec3 tipL = tip3D - twigPerp3D * twigHWt; vec3 tipR = tip3D + twigPerp3D * twigHWt; - // Horizontal projection of twigPerp for the FS varying (the FS reconstructs - // the cable normal via screen-space derivatives + this horizontal hint). - vec2 twigPerpH = vec2(twigPerp3D.x, twigPerp3D.z); - float lh = length(twigPerpH); - if (lh > 1e-4) twigPerpH /= lh; else twigPerpH = vec2(1.0, 0.0); - // cableUV.x carries the cable-wide along distance so the FS growth gate // hides this twig until the main growth front has reached spawnAlongMain. - // localU is twig-local along (0..bLen) — the FS uses it for the synced - // single-bubble animation in twigs (independent of cable-global phase). + // localU is twig-local along (0..bLen) — the FS uses it for synced single- + // bubble animation in twigs. Pass twigPerp3D in (the FS only uses the .xz + // horizontal projection from `perp` to sign-align the reconstructed + // surface-tangent perp3D, but emitVtx accepts vec3 so caller stays clean). gOutBranch = 1.0; gOutLocalU = 0.0; - emitVtx(rootL, twigPerpH, vec2(spawnAlongMain, -1.0), twigW, gridD, timeD, cap); - emitVtx(rootR, twigPerpH, vec2(spawnAlongMain, 1.0), twigW, gridD, timeD, cap); + emitVtx(rootL, twigPerp3D, vec2(spawnAlongMain, -1.0), twigW, gridD, timeD, cap); + emitVtx(rootR, twigPerp3D, vec2(spawnAlongMain, 1.0), twigW, gridD, timeD, cap); gOutLocalU = bLen; - emitVtx(tipL, twigPerpH, vec2(spawnAlongMain + bLen, -1.0), twigW * 0.25, gridD, timeD, cap); - emitVtx(tipR, twigPerpH, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.25, gridD, timeD, cap); + emitVtx(tipL, twigPerp3D, vec2(spawnAlongMain + bLen, -1.0), twigW * 0.25, gridD, timeD, cap); + emitVtx(tipR, twigPerp3D, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.25, gridD, timeD, cap); EndPrimitive(); } @@ -1640,10 +1645,10 @@ in DataGS { float isBranch; float width; vec2 cableUV; - vec2 perp; - vec2 timeData; // x = appearTime, y = witherTime (0 = not withering) - vec4 gridData; // x = efficiency (E/M), y = flow (E/s), z = bubble phase at bake (elmos), w = isOwnAlly (1 own, 0 enemy) - float localU; // twig-local along (0 at root, bLen at tip). Unused for main ribbon. + vec2 perp; // horizontal (XZ) projection of slope-aligned cross direction + vec2 timeData; + vec4 gridData; + float localU; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1788,17 +1793,21 @@ void main() { // Cylinder cross-section normal that respects cable slope. // - // `perp` is the *horizontal* cross-section direction baked at the - // vertex. The cable's true tangent in world space (which can have a Y - // component when the cable climbs/descends) is reconstructed from - // screen-space derivatives of `cableUV.x` (= along) versus worldPos — - // this works because `along` is monotone along the cable and screen - // derivatives sample along the surface. With both vectors known, the - // real "up" direction relative to the cable is cross(tangent, perp); - // this rotates with the slope, so an uphill cable shades brightest on - // its actual top side instead of where a horizontal cable would. - vec3 perp3D = normalize(vec3(perp.x, 0.0, perp.y)); - + // `perp` (vec2) is only the horizontal (XZ) projection of the slope-aligned + // cross direction — the GS varying budget on this hardware is tight, so we + // can't afford a full vec3. We reconstruct the actual 3D cross direction + // here in the FS, in two steps: + // (1) `cableT` = world-space cable tangent, recovered from screen-space + // derivatives of `cableUV.x` (along) vs worldPos. + // (2) `surfN` = visible surface normal at this fragment, recovered as + // `cross(dWdx, dWdy)`. This is the cable-ribbon's local + // surface plane normal — i.e., the slope's normal where + // the cable is laying. + // (3) `perp3D` = `cross(cableT, surfN)`, signed-aligned with the + // horizontal hint `perp.xy`. + // This way a cable on a cross-slope correctly tilts its cylindrical + // cross-section into the slope's plane (no corkscrew) without baking the Y + // component as a separate varying. vec3 dWdx = dFdx(worldPos); vec3 dWdy = dFdy(worldPos); float duDx = dFdx(cableUV.x); @@ -1813,6 +1822,17 @@ void main() { cableT = normalize(vec3(perp.y, 0.0, -perp.x)); } + vec3 surfaceN = cross(dWdx, dWdy); + float surfaceNL = length(surfaceN); + if (surfaceNL > 1e-4) surfaceN /= surfaceNL; else surfaceN = vec3(0.0, 1.0, 0.0); + if (surfaceN.y < 0.0) surfaceN = -surfaceN; + vec3 perp3D = cross(cableT, surfaceN); + float perp3DL = length(perp3D); + if (perp3DL > 1e-4) perp3D /= perp3DL; else perp3D = vec3(perp.x, 0.0, perp.y); + // Sign-align with the horizontal hint so the L/R sides match what the GS + // emitted (left vertex still carries cableUV.y = -1). + if (dot(perp3D.xz, perp) < 0.0) perp3D = -perp3D; + vec3 trueUp = cross(cableT, perp3D); if (trueUp.y < 0.0) trueUp = -trueUp; // ensure pointing skyward trueUp = normalize(trueUp); @@ -1848,10 +1868,28 @@ void main() { float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); float fullLOS = smoothstep(0.7, 1.0, losState); - // Enemy cables: only show in actual LOS (no ghost / no out-of-LOS render). - // Own cables show even out-of-LOS, dimmed (existing dimFactor handles it). + // Enemy cables out of LOS: render as a flat dim ghost reflecting the last + // known state (the synced gadget broadcasts every ally team's grid to all + // clients, so we already hold the last-received topology even after LOS + // is lost). Skip live shading + bubble animation — ghost is static so it + // reads as "memory" rather than current activity. Own cables stay live + // (they're always visible to the local viewer). + // + // We early-out here so the bubble layer pass and bark lighting below + // don't run for ghosts; they'd be wasted work. float isOwnAlly = gridData.w; - if (isOwnAlly < 0.5 && losState < 0.45) discard; + if (isOwnAlly < 0.5 && losState < 0.45) { + // Capacity-tinted ghost: thicker grid lines glow slightly more. + float capT = clamp(capacity / 100.0, 0.0, 1.0); + // Electric blue, desaturated. Branches are dimmer than trunk. + vec3 ghostBase = mix(vec3(0.10, 0.18, 0.35), vec3(0.20, 0.36, 0.65), capT); + if (isBranch > 0.5) ghostBase *= 0.55; + // Edge falloff so the ribbon edges fade rather than hard-cut, giving + // the ghost a softer "remembered impression" look. + float edgeFade = 1.0 - smoothstep(0.55, 0.90, t); + fragColor = vec4(ghostBase * edgeFade, 1.0); + return; + } // Apply lighting vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.85) * spec; From 3a7c6372238d8b31a209c8c86e12547abb233784 Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 23:03:21 +0200 Subject: [PATCH 34/59] corkscrew fix 2 --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 165 ++++++++++++++-------- 1 file changed, 108 insertions(+), 57 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 5d7cd527ad..df19cff5cc 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1410,6 +1410,38 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, 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; + for (int i = 0; i <= numSeg; i++) { float t = float(i) / float(numSeg); vec2 base = arcBiasedCenter(a, d, perpAB, t, lenAB, arcDh); @@ -1417,28 +1449,40 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(t); vec2 p = base + perpAB * n; - // Build the cable's local cross-section in the slope's tangent plane at - // `p` so the visible 3D L→R distance is exactly `widthVal` regardless of - // terrain tilt. The previous version offset L/R purely in horizontal XZ - // then sampled height independently, which made the visible cross-section - // inflate on cross-slopes (sqrt((2*halfW)^2 + (yL-yR)^2) > 2*halfW). - // Now: N = terrain normal at p; T = horizontal cable tangent projected - // into the slope plane; B = N × T. L = center3D + B*halfW (with B's sign - // chosen to land on `-perpAB`), R = center3D - B*halfW. - float yC = heightAtWorldPos(p) + 3.0; + // Anti-underground (along-cable): linear interpolation between two + // adjacent segment vertices can dip below terrain on convex/rolling + // slopes (the chord cuts under the heightmap between samples). Lift + // the centerline Y to the MAX heightmap value sampled within a window + // that COVERS THE FULL SEGMENT to either side of this vertex — i.e. + // up to the next vertex's position. That way adjacent vertices' max + // envelopes overlap at the segment midpoint, and the linearly + // interpolated ribbon between them stays above any terrain peak in + // the gap. Earlier the window was 0.95 × half-step which JUST missed + // the segment midpoint, leaving a thin band where the cable could + // still dip under (and bubbles got z-occluded in those spots). + float yC = heightAtWorldPos(p); + { + vec2 dirH = (lenAB > 0.0) ? d / lenAB : vec2(1.0, 0.0); + float fullStep = lenAB / float(numSeg); // full segment span + yC = max(yC, heightAtWorldPos(p + dirH * (fullStep * 0.30))); + yC = max(yC, heightAtWorldPos(p - dirH * (fullStep * 0.30))); + yC = max(yC, heightAtWorldPos(p + dirH * (fullStep * 0.55))); + yC = max(yC, heightAtWorldPos(p - dirH * (fullStep * 0.55))); + yC = max(yC, heightAtWorldPos(p + dirH * (fullStep * 0.85))); + yC = max(yC, heightAtWorldPos(p - dirH * (fullStep * 0.85))); + } + yC += 3.0; vec3 center3D = vec3(p.x, yC, p.y); - vec3 N = terrainNormal(p); - vec3 cableDirH = normalize(vec3(d.x, 0.0, d.y)); - vec3 T3 = normalize(cableDirH - dot(cableDirH, N) * N); - vec3 B3 = normalize(cross(N, T3)); - // Align B3 with -perpAB so cableUV.y signs match the prior convention - // (left vertex carries cableUV.y = -1). - vec3 perpRefH = normalize(vec3(-perpAB.x, 0.0, -perpAB.y)); - if (dot(B3, perpRefH) < 0.0) B3 = -B3; - - vec3 leftPos = center3D + B3 * halfW; - vec3 rightPos = center3D - B3 * halfW; + // 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 @@ -1879,15 +1923,20 @@ void main() { // don't run for ghosts; they'd be wasted work. float isOwnAlly = gridData.w; if (isOwnAlly < 0.5 && losState < 0.45) { - // Capacity-tinted ghost: thicker grid lines glow slightly more. + // Neutral grayish ghost — a "remembered" cable, not a live circuit. + // Capacity barely tints brightness so thicker grid lines read slightly + // brighter without picking up a hue. Branches are a touch dimmer. float capT = clamp(capacity / 100.0, 0.0, 1.0); - // Electric blue, desaturated. Branches are dimmer than trunk. - vec3 ghostBase = mix(vec3(0.10, 0.18, 0.35), vec3(0.20, 0.36, 0.65), capT); - if (isBranch > 0.5) ghostBase *= 0.55; + vec3 ghostBase = mix(vec3(0.30), vec3(0.55), capT); + if (isBranch > 0.5) ghostBase *= 0.65; // Edge falloff so the ribbon edges fade rather than hard-cut, giving // the ghost a softer "remembered impression" look. float edgeFade = 1.0 - smoothstep(0.55, 0.90, t); - fragColor = vec4(ghostBase * edgeFade, 1.0); + // Alpha < 1 so the ghost composes against the world (DrawWorldPreUnit + // enables alpha blending). Edge fade also drives alpha so the silhouette + // dissolves smoothly rather than hard-clipping at t=0.9. + float ghostA = 0.55 * edgeFade; + fragColor = vec4(ghostBase * edgeFade, ghostA); return; } @@ -1946,38 +1995,30 @@ void main() { // at the main cable's speed. float bubbleBody, bubbleSpec, bubbleHalo; if (isBranch > 0.5) { - // "Electric crackle" — synchronized power buzzing on twigs. - // - // CRITICAL constraint: every spatial reference inside this branch must - // be cross-cable only (`v`), never along-twig. Any along-twig gradient - // keyed off a temporally advancing scalar can be read as motion, and a - // motion that wraps around at phase reset reads as "backwards bouncing". - // We use ONLY `phase` (time-like, per-cable, shared across all twigs) and - // `v` (cross-cable shape). No `localU`, no `along`, no `worldPos`-along. - // - // Two stacked uniform-in-space components, both functions of phase only: - // * crackle: high-frequency hash-driven flicker, ticks per ~5 elmos of - // phase. Reads as electric arcing in the conduit. Brightness is a - // random scalar per tick, identical across the entire twig surface. - // * zap: occasional bigger Gaussian-shaped pulse, ~1 per 220 elmos - // of phase. Reads as a "power surge" hitting the twigs. + // Discrete synchronized flash — the entire twig blinks ON for a brief + // window, then OFF, then ON again, etc. No along-twig gradient, no + // continuous pulsing/crackle. Every twig of a cable shares `phase`, so + // all twigs in the cable flash at the same instant. Phase advance rate + // = speed, so faster grids flash more often. // - // All twigs of a cable share `phase`, so they crackle / zap in lockstep. - // Phase advance rate = speed, so faster grids buzz / zap faster. - const float TICK = 5.0; // elmos of phase per crackle tick - const float ZAP_PER = 220.0; // elmos of phase per power zap - float tickIdx = floor(phase / TICK); - float h0 = hash1(tickIdx); - float h1 = hash1(tickIdx + 17.3); - float crackle = h0 * h0 * step(0.55, h1); // sparse + biased dim - float zapPhase = mod(phase, ZAP_PER) / ZAP_PER; - float zd = zapPhase - 0.08; // peak shortly after wrap - float zap = exp(-zd * zd * 90.0); // Gaussian, sharp - float cross = 1.0 - smoothstep(0.6, 1.0, v * v); - float intensity = (crackle * 0.55 + zap * 1.10) * cross; + // Why a square-ish window with smoothstep edges (instead of a + // continuous Gaussian/sine): the user wants "all on at once, then off", + // not a wave. The 0.012-wide smoothstep edges only exist to avoid hard + // frame-boundary popping; mid-window the brightness is flat 1.0. + const float FLASH_PERIOD = 160.0; // elmos of phase between flashes + const float FLASH_ON = 0.10; // duty cycle (fraction of period lit) + const float EDGE = 0.012; + float cyc = mod(phase, FLASH_PERIOD) / FLASH_PERIOD; + float flashOn = smoothstep(0.0, EDGE, cyc) + * (1.0 - smoothstep(FLASH_ON - EDGE, FLASH_ON, cyc)); + // Mild cross-axis taper so the edge of the ribbon isn't quite as bright + // as the centre — gives the flash some volume rather than reading as a + // flat painted card. No along-twig variation. + float crossT = 1.0 - smoothstep(0.7, 1.0, v * v); + float intensity = flashOn * crossT * 1.5; bubbleBody = intensity * 1.20; - bubbleSpec = intensity * 0.70; - bubbleHalo = intensity * 0.50; + bubbleSpec = intensity * 0.65; + bubbleHalo = intensity * 0.55; } else { vec3 bA = bubbleLayer(along, phase, spacingA, 7.5, v, halfWidthE, 3.7); vec3 bB = bubbleLayer(along, phase, spacingB, 4.0, v, halfWidthE, 19.1); @@ -2228,7 +2269,15 @@ function gadget:DrawWorldPreUnit() if not cableVAO or numCableVerts == 0 or not cableShader then return end cableShader:Activate() - cableShader:SetUniform("gameTime", Spring.GetGameSeconds()) + -- 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) gl.Texture(0, "$info") @@ -2236,7 +2285,9 @@ function gadget:DrawWorldPreUnit() gl.Culling(false) gl.DepthTest(GL.LEQUAL) gl.DepthMask(true) - gl.Blending(false) + -- Standard alpha blending. Live cables write alpha=1.0 (visually identical + -- to no-blend), ghosts write alpha<1.0 to compose against the world. + gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) -- GL_LINES: every 2 verts form one cable; the geometry shader expands -- them into a triangle_strip ribbon. From bc95a681159672e792012423bc03e0d08766adfb Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 23:32:16 +0200 Subject: [PATCH 35/59] undimmable balls --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 146 +++++++++++----------- 1 file changed, 74 insertions(+), 72 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index df19cff5cc..62081c0fc2 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1260,10 +1260,8 @@ out DataGS { float isBranch; float width; vec2 cableUV; - vec2 perp; // horizontal (XZ) projection of slope-aligned cross direction vec2 timeData; vec4 gridData; - float localU; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1321,25 +1319,23 @@ const float BRANCH_WIDTH = 0.85; float gOutBranch = 0.0; -float gOutLocalU = 0.0; // set per-vertex by twig emitters; main ribbon leaves at 0. - -void emitVtx(vec3 wp, vec3 perpHere, vec2 cuv, +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; - // We bake only the *horizontal* projection of the slope-aligned cross - // direction. The FS reconstructs the full 3D direction by rotating it - // into the local surface tangent plane (using screen-space derivatives - // of worldPos), so we don't need to spend a fresh varying on the Y - // component — the GS-output varying budget on this hardware is tight - // and adding any extra component pushes the linker over. - perp = perpHere.xz; timeData = td; gridData = grid; - localU = gOutLocalU; + // Per-vertex cable along-direction. Smoothly interpolated across the + // triangle strip → the FS gets a continuously rotating tangent across + // the cable, so the cylinder cross-section's lit direction tracks the + // cable's path (highlight bends with up/down hills) instead of being + // flat-shaded per triangle. perp3D and trueUp are derived from this in + // the FS as cross(worldUp, tangent), so we don't need a separate `perp` + // varying — the cross-section axis is implied by the smooth tangent. + // (vsTangent varying disabled — exceeded GS output budget on this hardware) gl_Position = cameraViewProj * vec4(wp, 1.0); EmitVertex(); } @@ -1500,14 +1496,27 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, 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; - // Pass B3 (slope-aligned cross direction) as the perp varying — the FS - // reconstructs the cylinder cross-coordinate from this. Using horizontal - // perpAB here previously caused a corkscrew/twist on cross-slopes. - emitVtx(leftPos, B3, vec2(along, -1.0), widthVal, gridD, timeD, cap); - emitVtx(rightPos, B3, vec2(along, 1.0), widthVal, gridD, timeD, cap); + emitVtx(leftPos, vtxTangent, vec2(along, -1.0), widthVal, gridD, timeD, cap); + emitVtx(rightPos, vtxTangent, vec2(along, 1.0), widthVal, gridD, timeD, cap); } EndPrimitive(); } @@ -1576,17 +1585,14 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, // cableUV.x carries the cable-wide along distance so the FS growth gate // hides this twig until the main growth front has reached spawnAlongMain. - // localU is twig-local along (0..bLen) — the FS uses it for synced single- - // bubble animation in twigs. Pass twigPerp3D in (the FS only uses the .xz - // horizontal projection from `perp` to sign-align the reconstructed - // surface-tangent perp3D, but emitVtx accepts vec3 so caller stays clean). + // 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; - gOutLocalU = 0.0; - emitVtx(rootL, twigPerp3D, vec2(spawnAlongMain, -1.0), twigW, gridD, timeD, cap); - emitVtx(rootR, twigPerp3D, vec2(spawnAlongMain, 1.0), twigW, gridD, timeD, cap); - gOutLocalU = bLen; - emitVtx(tipL, twigPerp3D, vec2(spawnAlongMain + bLen, -1.0), twigW * 0.25, gridD, timeD, cap); - emitVtx(tipR, twigPerp3D, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.25, gridD, timeD, cap); + 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 * 0.25, gridD, timeD, cap); + emitVtx(tipR, twigDir3D, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.25, gridD, timeD, cap); EndPrimitive(); } @@ -1689,10 +1695,8 @@ in DataGS { float isBranch; float width; vec2 cableUV; - vec2 perp; // horizontal (XZ) projection of slope-aligned cross direction vec2 timeData; vec4 gridData; - float localU; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1835,48 +1839,42 @@ void main() { if (along < witherFront) discard; } - // Cylinder cross-section normal that respects cable slope. + // 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). // - // `perp` (vec2) is only the horizontal (XZ) projection of the slope-aligned - // cross direction — the GS varying budget on this hardware is tight, so we - // can't afford a full vec3. We reconstruct the actual 3D cross direction - // here in the FS, in two steps: - // (1) `cableT` = world-space cable tangent, recovered from screen-space - // derivatives of `cableUV.x` (along) vs worldPos. - // (2) `surfN` = visible surface normal at this fragment, recovered as - // `cross(dWdx, dWdy)`. This is the cable-ribbon's local - // surface plane normal — i.e., the slope's normal where - // the cable is laying. - // (3) `perp3D` = `cross(cableT, surfN)`, signed-aligned with the - // horizontal hint `perp.xy`. - // This way a cable on a cross-slope correctly tilts its cylindrical - // cross-section into the slope's plane (no corkscrew) without baking the Y - // component as a separate varying. - vec3 dWdx = dFdx(worldPos); - vec3 dWdy = dFdy(worldPos); + // 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 denom = duDx * duDx + duDy * duDy; - vec3 cableT; - if (denom > 1e-6) { - cableT = normalize((dWdx * duDx + dWdy * duDy) / denom); + 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 { - // Fallback if derivatives are degenerate (single-pixel cable, etc.): - // horizontal tangent perpendicular to perp. - cableT = normalize(vec3(perp.y, 0.0, -perp.x)); + // Cable nearly vertical — pick an arbitrary horizontal perp. + perp3D = vec3(1.0, 0.0, 0.0); } - vec3 surfaceN = cross(dWdx, dWdy); - float surfaceNL = length(surfaceN); - if (surfaceNL > 1e-4) surfaceN /= surfaceNL; else surfaceN = vec3(0.0, 1.0, 0.0); - if (surfaceN.y < 0.0) surfaceN = -surfaceN; - vec3 perp3D = cross(cableT, surfaceN); - float perp3DL = length(perp3D); - if (perp3DL > 1e-4) perp3D /= perp3DL; else perp3D = vec3(perp.x, 0.0, perp.y); - // Sign-align with the horizontal hint so the L/R sides match what the GS - // emitted (left vertex still carries cableUV.y = -1). - if (dot(perp3D.xz, perp) < 0.0) perp3D = -perp3D; - vec3 trueUp = cross(cableT, perp3D); if (trueUp.y < 0.0) trueUp = -trueUp; // ensure pointing skyward trueUp = normalize(trueUp); @@ -2035,8 +2033,16 @@ void main() { vec3 bubbleColor = mix(grayedGrid, vec3(1.0), 0.15); vec3 haloColor = grayedGrid; - // Composition order is chosen so the bubble core never picks up the bark's - // hue: + // LOS-aware dimming on the BARK ONLY. Bubbles are plasma — they're emissive + // and shouldn't fade with LOS-darkness; previously the dim was applied + // after bubble composition, which made the glowing balls visibly disappear + // as they crossed dim/shadow regions. Now the bark dims, then bubbles are + // composed at full emissive brightness on top, so they remain visible as + // "lights in the dark". + float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); + color *= dimFactor; + + // Composition order: // - Halo: additive (soft underglow that should mix with bark colour). // - Body: max() over current colour, so the dark green/brown bark can't // leak into the bubble's true grid hue. Plain additive composition @@ -2049,10 +2055,6 @@ void main() { color = max(color, bubbleEmissive); color += vec3(1.0) * bubbleSpec * fullLOS * 1.10; - // LOS-aware dimming - float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); - color *= dimFactor; - // FULLY OPAQUE output — like lava. No alpha blending. fragColor = vec4(color, 1.0); } From 6d877ae42389b43af4c93a002f44b506799db8cb Mon Sep 17 00:00:00 2001 From: Licho Date: Wed, 29 Apr 2026 23:43:07 +0200 Subject: [PATCH 36/59] twig --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 66 +++++++++++------------ 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 62081c0fc2..acf4dc8b74 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1467,7 +1467,7 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, yC = max(yC, heightAtWorldPos(p + dirH * (fullStep * 0.85))); yC = max(yC, heightAtWorldPos(p - dirH * (fullStep * 0.85))); } - yC += 3.0; + yC += 1.5; // centerline clearance — was 3.0; max-of-window lift already prevents under-terrain dips so we don't need a big pad on top. vec3 center3D = vec3(p.x, yC, p.y); // Geometry convention MUST match the twig emitter: @@ -1485,7 +1485,7 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, // below the actual heightmap at their XZ. Raise to local terrain + // minClearance whenever that happens. On linear terrain the L/R points // already sit at clearance above ground so this is a no-op there. - float minSideClearance = 1.5; + float minSideClearance = 0.8; float hL_xz = heightAtWorldPos(leftPos.xz) + minSideClearance; float hR_xz = heightAtWorldPos(rightPos.xz) + minSideClearance; leftPos.y = max(leftPos.y, hL_xz); @@ -1528,7 +1528,7 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, 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) { + float spawnAlongMain, int twigIdx, float arcDh, int numSeg) { // Resolve spawn point on the wiggly main path at tCenter. Apply the same // arc bias as the main ribbon so twigs root on the visible cable. vec2 base = arcBiasedCenter(a, d, perpAB, tCenter, length(d), arcDh); @@ -1570,8 +1570,25 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, vec3 twigDir3D = ca * T + sa * B; vec3 twigPerp3D = normalize(cross(N, twigDir3D)); - float clearance = 2.5; - vec3 spawn3D = vec3(spawn.x, heightAtWorldPos(spawn), spawn.y) + N * clearance; + // Anchor the spawn at the SAME height the main ribbon's centerline sits at + // for this t — i.e., apply the same max-of-window lift that emitMainRibbon + // uses. Without this, on slopes spawn3D = local-terrain + small clearance, + // while the main cable's centerline = max-over-window + clearance, so the + // twig visibly detaches from the cable trunk and floats just above terrain. + float spawnYc = heightAtWorldPos(spawn); + { + float lenAB = length(d); + vec2 dirH = (lenAB > 0.0) ? d / lenAB : vec2(1.0, 0.0); + float fullStep = lenAB / float(numSeg); + spawnYc = max(spawnYc, heightAtWorldPos(spawn + dirH * (fullStep * 0.30))); + spawnYc = max(spawnYc, heightAtWorldPos(spawn - dirH * (fullStep * 0.30))); + spawnYc = max(spawnYc, heightAtWorldPos(spawn + dirH * (fullStep * 0.55))); + spawnYc = max(spawnYc, heightAtWorldPos(spawn - dirH * (fullStep * 0.55))); + spawnYc = max(spawnYc, heightAtWorldPos(spawn + dirH * (fullStep * 0.85))); + spawnYc = max(spawnYc, heightAtWorldPos(spawn - dirH * (fullStep * 0.85))); + } + spawnYc += 0.9; // 0.6 elmos below main ribbon's centerline (which is +1.5) — avoids z-fighting at the junction while keeping the twig visually attached to the trunk. + 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. @@ -1675,7 +1692,7 @@ void main() { / 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); + gridD, timeD, cap, tCenter, float(idx) * 13.7, spawnAlongMain, idx, arcDh, numSeg); } } ]] @@ -1987,37 +2004,14 @@ void main() { float spacingA = 105.0 / spacingMul; float spacingB = 48.0 / spacingMul; - // Bubble pass: main ribbon uses two layers of advecting bubbles whose - // spacing is modulated by densityFactor. Twigs instead show synced bubbles - // at the same big-bubble rhythm so every twig in a cable pulses in lockstep - // at the main cable's speed. + // Bubble pass: same advecting-bubble layers on both main ribbon and twigs. + // Twigs share `phase` and `speed` with the cable, so a bubble crossing the + // junction continues into the twig at the same flow rate; on a long twig + // you'll see one or two travelling balls heading from cable toward tip + // (since cableUV.x increases with twig-along), matching the main cable's + // fluid look rather than a strobe flash. float bubbleBody, bubbleSpec, bubbleHalo; - if (isBranch > 0.5) { - // Discrete synchronized flash — the entire twig blinks ON for a brief - // window, then OFF, then ON again, etc. No along-twig gradient, no - // continuous pulsing/crackle. Every twig of a cable shares `phase`, so - // all twigs in the cable flash at the same instant. Phase advance rate - // = speed, so faster grids flash more often. - // - // Why a square-ish window with smoothstep edges (instead of a - // continuous Gaussian/sine): the user wants "all on at once, then off", - // not a wave. The 0.012-wide smoothstep edges only exist to avoid hard - // frame-boundary popping; mid-window the brightness is flat 1.0. - const float FLASH_PERIOD = 160.0; // elmos of phase between flashes - const float FLASH_ON = 0.10; // duty cycle (fraction of period lit) - const float EDGE = 0.012; - float cyc = mod(phase, FLASH_PERIOD) / FLASH_PERIOD; - float flashOn = smoothstep(0.0, EDGE, cyc) - * (1.0 - smoothstep(FLASH_ON - EDGE, FLASH_ON, cyc)); - // Mild cross-axis taper so the edge of the ribbon isn't quite as bright - // as the centre — gives the flash some volume rather than reading as a - // flat painted card. No along-twig variation. - float crossT = 1.0 - smoothstep(0.7, 1.0, v * v); - float intensity = flashOn * crossT * 1.5; - bubbleBody = intensity * 1.20; - bubbleSpec = intensity * 0.65; - bubbleHalo = intensity * 0.55; - } else { + { vec3 bA = bubbleLayer(along, phase, spacingA, 7.5, v, halfWidthE, 3.7); vec3 bB = bubbleLayer(along, phase, spacingB, 4.0, v, halfWidthE, 19.1); bubbleBody = bA.x + bB.x * 0.85; From 84da2ab9ae2b574320ffe0b73dc2895b173ffdcf Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 00:15:42 +0200 Subject: [PATCH 37/59] perfect --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 84 ++++++++++++++++++----- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index acf4dc8b74..8e1adc908f 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1262,6 +1262,7 @@ out DataGS { vec2 cableUV; vec2 timeData; vec4 gridData; + float spawnAlongMain; // twig-only: global cableUV.x of the twig's root; 0 for main ribbon. Lets the FS compute twig-local along for sub-wave animation. }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -1318,6 +1319,7 @@ const float BRANCH_ANGLE_MAX = 1.1; const float BRANCH_WIDTH = 0.85; float gOutBranch = 0.0; +float gOutSpawnAlong = 0.0; // set by emitTwig per-twig; main ribbon leaves at 0. void emitVtx(vec3 wp, vec3 tangent3D, vec2 cuv, float w, vec4 grid, vec2 td, float cap) { @@ -1328,13 +1330,7 @@ void emitVtx(vec3 wp, vec3 tangent3D, vec2 cuv, cableUV = cuv; timeData = td; gridData = grid; - // Per-vertex cable along-direction. Smoothly interpolated across the - // triangle strip → the FS gets a continuously rotating tangent across - // the cable, so the cylinder cross-section's lit direction tracks the - // cable's path (highlight bends with up/down hills) instead of being - // flat-shaded per triangle. perp3D and trueUp are derived from this in - // the FS as cross(worldUp, tangent), so we don't need a separate `perp` - // varying — the cross-section axis is implied by the smooth tangent. + spawnAlongMain = gOutSpawnAlong; // (vsTangent varying disabled — exceeded GS output budget on this hardware) gl_Position = cameraViewProj * vec4(wp, 1.0); EmitVertex(); @@ -1551,7 +1547,17 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, float twigW = max(2.5, widthVal * BRANCH_WIDTH); float twigHWr = min(twigW, widthVal * 0.55) * WIDTH_FACTOR; - float twigHWt = twigHWr * 0.25; + // 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 @@ -1606,11 +1612,13 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, // 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 * 0.25, gridD, timeD, cap); - emitVtx(tipR, twigDir3D, vec2(spawnAlongMain + bLen, 1.0), twigW * 0.25, 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() { @@ -1714,6 +1722,7 @@ in DataGS { vec2 cableUV; vec2 timeData; vec4 gridData; + float spawnAlongMain; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -2004,14 +2013,55 @@ void main() { float spacingA = 105.0 / spacingMul; float spacingB = 48.0 / spacingMul; - // Bubble pass: same advecting-bubble layers on both main ribbon and twigs. - // Twigs share `phase` and `speed` with the cable, so a bubble crossing the - // junction continues into the twig at the same flow rate; on a long twig - // you'll see one or two travelling balls heading from cable toward tip - // (since cableUV.x increases with twig-along), matching the main cable's - // fluid look rather than a strobe flash. + // Bubble pass: main ribbon uses two advecting bubble layers; twigs do a + // single synchronized flash (whole twig glows at the same instant, once + // per `FLASH_PERIOD` elmos of phase). All twigs share `phase`, so every + // twig of a cable flashes in lockstep at a rate driven by the main-cable + // speed — no per-twig flow, just a discrete pulse signalling "power + // pulse reached the limb". float bubbleBody, bubbleSpec, bubbleHalo; - { + if (isBranch > 0.5) { + // Two-stage wavefront: + // 1. CABLE_PROP_SPEED is a FAST virtual wave that sweeps along the + // cable's `along` axis. Twigs at lower spawnAlongMain get "hit" + // earlier, encoding direction-from-root via stagger. + // 2. When the cable wave passes a twig's root, a SLOWER sub-wave + // starts at twig-local 0 and propagates through that twig at + // TWIG_SWEEP_SPEED. This way the inter-twig stagger feels + // snappy while the visible motion *within* each twig stays at + // a comfortable speed. + // Without spawnAlongMain we couldn't decouple these — both speeds + // would be tied to the same propagation rate. + const float CABLE_PROP_SPEED = 400.0; // elmos/sec — fast inter-twig stagger + // Recurrence period: 2800 elmos / 400 elmos/sec = 7 sec between waves. + // Made the wave a sparse "every several seconds" event rather than a + // constant pulse train, so it reads as a periodic energy surge instead + // of nervous flicker. + const float CABLE_PROP_PERIOD = 2800.0; // elmos + const float TWIG_SWEEP_SPEED = 90.0; // elmos/sec — visible motion within a twig + const float PULSE_HW = 5.0; // Gaussian sigma in elmos + + // Elmos along the cable since the wave passed THIS twig's root + // (wraps every CABLE_PROP_PERIOD elmos). Subtracting spawnAlongMain + // from `gameTime * speed` gives a per-twig "time since root was hit" + // in elmos-of-cable-wave-travel. + float wavePassedElmos = mod(gameTime * CABLE_PROP_SPEED - spawnAlongMain, CABLE_PROP_PERIOD); + // Convert to seconds, then to twig-local sub-wave position. + float subwavePos = TWIG_SWEEP_SPEED * (wavePassedElmos / CABLE_PROP_SPEED); + + // Fragment's twig-local along (0 at root, bLen at tip). + float localAlong = along - spawnAlongMain; + float d = localAlong - subwavePos; + // No wrap correction needed: when subwavePos overshoots the twig + // length the Gaussian naturally falls to ~0 (fragment is too far + // from the now-passed sub-wave). + float pulse = exp(-(d * d) / (PULSE_HW * PULSE_HW)); + float crossT = 1.0 - smoothstep(0.7, 1.0, v * v); + float intensity = pulse * crossT * 0.55; + bubbleBody = intensity * 1.10; + bubbleSpec = intensity * 0.55; + bubbleHalo = intensity * 0.50; + } else { vec3 bA = bubbleLayer(along, phase, spacingA, 7.5, v, halfWidthE, 3.7); vec3 bB = bubbleLayer(along, phase, spacingB, 4.0, v, halfWidthE, 19.1); bubbleBody = bA.x + bB.x * 0.85; From 3866ab84a53a7fc10c47ba053b71c5444fad0a2a Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 00:52:34 +0200 Subject: [PATCH 38/59] cleaner --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 546 ++++++++++++++-------- 1 file changed, 340 insertions(+), 206 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 8e1adc908f..d844401f7b 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -185,8 +185,13 @@ do end -- Runtime toggles, driven by the /cabletree chat command (see CableTreeCmd). -local cableEnabled = true -local cablePerf = false +-- cableFlowMode = full bubble animation + per-tick consumer draw reads. +-- cableFlowMode = false: static cables, no per-tick reads, no bubbles. +-- Persistence happens on the unsynced side (Spring.GetConfigInt is +-- unsynced-only); unsynced bootstraps synced via a Lua rules msg on init. +local cableEnabled = true +local cablePerf = false +local cableFlowMode = true ------------------------------------------------------------------------------------- -- Helpers @@ -630,52 +635,59 @@ local function BuildMpCache() mpCache.valid = true end -local function ComputeMaxPotentials() +-- `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) if not mpCache.valid then BuildMpCache() end local order = mpCache.order local parentInTree = mpCache.parentInTree local componentRoot = mpCache.componentRoot local subPmax = mpCache.subPmax local subDmax = mpCache.subDmax - local subPmaxNonWind = mpCache.subPmaxNonWind - local subWindCount = mpCache.subWindCount - local subWindBase = mpCache.subWindBase - - -- Per-tick wind globals: one read each, then everything is arithmetic. - local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 - local _, _, _, currStrength = Spring.GetWind() - currStrength = currStrength or 0 - local windFrac = (windMax > 0) and (currStrength / windMax) or 0 - if windFrac < 0 then windFrac = 0 elseif windFrac > 1 then windFrac = 1 end - - -- subPcur derived directly from cached aggregates — no per-node Pcur read. - -- Wind: linear-in-strength sum collapses to (1-f)*base + f*windMax*N. - -- Non-wind generators in MST are assumed to be producing nameplate - -- (any inactive generator has gridID=0 and so isn't in the cached tree). - local subPcur = {} - for i = 1, #order do - local u = order[i] - subPcur[u] = subWindBase[u] + windFrac * (windMax * subWindCount[u] - subWindBase[u]) - + subPmaxNonWind[u] - end - -- subDcur DOES still need per-node reads — mex draw and turret - -- consumption fluctuate per tick and are not derivable from anything - -- topology-cached. But we only call into the engine for nodes whose def - -- can possibly draw (mexes, voltage units, builders). Pure generators - -- (windmills/solar/fusion) and range-only pylons stay at 0 → at 1500+ - -- pylons this skips most of the per-tick rules-param reads. - local 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] + local subPcur, subDcur + if flowMode then + local subPmaxNonWind = mpCache.subPmaxNonWind + local subWindCount = mpCache.subWindCount + local subWindBase = mpCache.subWindBase + + -- Per-tick wind globals: one read each, then everything is arithmetic. + local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 + local _, _, _, currStrength = Spring.GetWind() + currStrength = currStrength or 0 + local windFrac = (windMax > 0) and (currStrength / windMax) or 0 + if windFrac < 0 then windFrac = 0 elseif windFrac > 1 then windFrac = 1 end + + -- subPcur derived directly from cached aggregates — no per-node Pcur + -- read. Wind: linear-in-strength sum collapses to + -- (1-f)*base + f*windMax*N. Non-wind generators in MST are assumed to + -- be at nameplate (inactive ones have gridID=0 and aren't cached). + subPcur = {} + for i = 1, #order do + local u = order[i] + subPcur[u] = subWindBase[u] + windFrac * (windMax * subWindCount[u] - subWindBase[u]) + + subPmaxNonWind[u] + end + + -- subDcur DOES still need per-node reads — mex draw and turret + -- consumption fluctuate per tick. We restrict reads to nodes whose + -- def can possibly draw (mexes, voltage units, builders); pure + -- generators (windmills/solar/fusion) and range-only pylons stay at 0. + 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 @@ -700,19 +712,23 @@ local function ComputeMaxPotentials() capacities[key] = cap local potentialSrcSubtree = capAB > capBA - 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 local flow, flowSrcSubtree - if flowAB >= flowBA then - flow, flowSrcSubtree = flowAB, true + 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 = flowBA, false + flow, flowSrcSubtree = 0, potentialSrcSubtree end - if flow < 0 then flow = 0 end - if flow <= 0 then flowSrcSubtree = potentialSrcSubtree end flows[key] = flow local edge = edges[key] @@ -757,7 +773,7 @@ end ------------------------------------------------------------------------------------- local function SendAll() - local capacities, flows = ComputeMaxPotentials() + local capacities, flows = ComputeMaxPotentials(cableFlowMode) -- Bin edges by ally, in one pass. local perAlly = {} @@ -841,12 +857,15 @@ function gadget:GameFrame(n) if not cableEnabled then return end if n % SYNC_PERIOD == 2 then SyncWithGrid() - -- Always send: flow magnitudes and grid efficiency colour change every - -- tick, so unsynced needs the periodic refresh even when topology is - -- unchanged. Diff cost on the unsynced side is cheap (key lookup + - -- attribute upload); geometry only re-generates when keys change. - SendAll() - topologyDirty = false + -- 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. + if cableFlowMode or topologyDirty then + SendAll() + topologyDirty = false + end -- Synced has no timing API (Spring.GetTimer is unsynced-only, and the -- sandbox doesn't expose `os`). Just report the edge count from here; -- the real cost numbers come from the unsynced rebuild log, which @@ -859,8 +878,28 @@ function gadget:GameFrame(n) end end +-- Tells unsynced whether to gate the FS bubble pass. Synced is the source of +-- truth (chat command runs here); unsynced mirrors via SendToUnsynced. +local function PushFlowModeToUnsynced() + _G.CableTreeFlowMode = { flowMode = cableFlowMode } + SendToUnsynced("CableTreeFlowMode") +end + +local function SetFlowMode(on) + if cableFlowMode == on then return end + cableFlowMode = on + PushFlowModeToUnsynced() + -- Force one fresh send so the new mode takes effect immediately rather + -- than waiting for the next topology change (which might never come on + -- a settled grid). + SendAll() + topologyDirty = false +end + -- /cabletree — toggle on/off -- /cabletree on / off — explicit +-- /cabletree flow on/off — toggle bubble animation + per-tick flow reads +-- (the heavy path at 1500+ pylons) -- /cabletree perf — toggle per-cycle timing log -- /cabletree status — print current state local function CableTreeCmd(cmd, line, words, playerID) @@ -876,6 +915,12 @@ local function CableTreeCmd(cmd, line, words, playerID) cableEnabled = false ClearAll() Spring.Echo("[CableTree] OFF") + elseif arg == "flow" then + local sub = (words and words[2]) or "" + if sub == "on" then SetFlowMode(true) + elseif sub == "off" then SetFlowMode(false) + else SetFlowMode(not cableFlowMode) end + Spring.Echo("[CableTree] flow animation " .. (cableFlowMode and "ON" or "OFF")) elseif arg == "perf" then cablePerf = not cablePerf _G.CableTreePerf = { perf = cablePerf } @@ -885,10 +930,11 @@ local function CableTreeCmd(cmd, line, words, playerID) local nEdges = 0 for _ in pairs(edges) do nEdges = nEdges + 1 end Spring.Echo(string.format( - "[CableTree] enabled=%s perf=%s edges=%d", - tostring(cableEnabled), tostring(cablePerf), nEdges)) + "[CableTree] enabled=%s flow=%s perf=%s edges=%d", + tostring(cableEnabled), tostring(cableFlowMode), + tostring(cablePerf), nEdges)) else - Spring.Echo("[CableTree] usage: /cabletree [on|off|toggle|perf|status]") + Spring.Echo("[CableTree] usage: /cabletree [on|off|toggle|flow on/off|perf|status]") end return true end @@ -942,6 +988,18 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) end end +-- Unsynced bootstraps synced with the player's persisted flow setting via +-- Spring.SendLuaRulesMsg (see unsynced gadget:Initialize). Format: +-- "cabletree:flow:on" / "cabletree:flow:off". We only react to that exact +-- prefix; other messages pass through untouched. +function gadget:RecvLuaMsg(msg, playerID) + if msg == "cabletree:flow:on" then + SetFlowMode(true) + elseif msg == "cabletree:flow:off" then + SetFlowMode(false) + end +end + function gadget:Initialize() GG.CableTree = { nodes = nodes, edges = edges } gadgetHandler:AddChatAction("cabletree", CableTreeCmd) @@ -1085,6 +1143,10 @@ local cableShader -- forward shader for cable rendering local cableVAO -- live cable geometry local numCableVerts = 0 local drawPerf = false -- toggled by the synced /cabletree perf command +-- Mirrors synced cableFlowMode. Drives the FS `enableFlow` uniform; both +-- sides initialise from the same Spring config key so the toggle survives +-- reloads even before the synced side gets a chance to push. +local flowMode = (Spring.GetConfigInt("OverdriveCableFlow", 1) or 1) ~= 0 -- 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 @@ -1318,6 +1380,16 @@ 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; float gOutSpawnAlong = 0.0; // set by emitTwig per-twig; main ribbon leaves at 0. @@ -1392,6 +1464,34 @@ vec2 arcBiasedCenter(vec2 a, vec2 d, vec2 perpAB, float t, float lenAB, float dh 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. +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); + return base + perpAB * 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) { @@ -1434,36 +1534,15 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, 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 base = arcBiasedCenter(a, d, perpAB, t, lenAB, arcDh); - - float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(t); - vec2 p = base + perpAB * n; - - // Anti-underground (along-cable): linear interpolation between two - // adjacent segment vertices can dip below terrain on convex/rolling - // slopes (the chord cuts under the heightmap between samples). Lift - // the centerline Y to the MAX heightmap value sampled within a window - // that COVERS THE FULL SEGMENT to either side of this vertex — i.e. - // up to the next vertex's position. That way adjacent vertices' max - // envelopes overlap at the segment midpoint, and the linearly - // interpolated ribbon between them stays above any terrain peak in - // the gap. Earlier the window was 0.95 × half-step which JUST missed - // the segment midpoint, leaving a thin band where the cable could - // still dip under (and bubbles got z-occluded in those spots). - float yC = heightAtWorldPos(p); - { - vec2 dirH = (lenAB > 0.0) ? d / lenAB : vec2(1.0, 0.0); - float fullStep = lenAB / float(numSeg); // full segment span - yC = max(yC, heightAtWorldPos(p + dirH * (fullStep * 0.30))); - yC = max(yC, heightAtWorldPos(p - dirH * (fullStep * 0.30))); - yC = max(yC, heightAtWorldPos(p + dirH * (fullStep * 0.55))); - yC = max(yC, heightAtWorldPos(p - dirH * (fullStep * 0.55))); - yC = max(yC, heightAtWorldPos(p + dirH * (fullStep * 0.85))); - yC = max(yC, heightAtWorldPos(p - dirH * (fullStep * 0.85))); - } - yC += 1.5; // centerline clearance — was 3.0; max-of-window lift already prevents under-terrain dips so we don't need a big pad on top. + 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: @@ -1479,13 +1558,10 @@ void emitMainRibbon(vec2 a, vec2 d, vec2 perpAB, // 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 + - // minClearance whenever that happens. On linear terrain the L/R points + // 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. - float minSideClearance = 0.8; - float hL_xz = heightAtWorldPos(leftPos.xz) + minSideClearance; - float hR_xz = heightAtWorldPos(rightPos.xz) + minSideClearance; - leftPos.y = max(leftPos.y, hL_xz); - rightPos.y = max(rightPos.y, hR_xz); + 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). @@ -1525,11 +1601,10 @@ 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. Apply the same - // arc bias as the main ribbon so twigs root on the visible cable. - vec2 base = arcBiasedCenter(a, d, perpAB, tCenter, length(d), arcDh); - float n = gsHash(base.x * 0.1, base.y * 0.1, seed) * effAmp * gsNoiseScale(tCenter); - vec2 spawn = base + perpAB * n; + // 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); @@ -1576,24 +1651,13 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, vec3 twigDir3D = ca * T + sa * B; vec3 twigPerp3D = normalize(cross(N, twigDir3D)); - // Anchor the spawn at the SAME height the main ribbon's centerline sits at - // for this t — i.e., apply the same max-of-window lift that emitMainRibbon - // uses. Without this, on slopes spawn3D = local-terrain + small clearance, - // while the main cable's centerline = max-over-window + clearance, so the - // twig visibly detaches from the cable trunk and floats just above terrain. - float spawnYc = heightAtWorldPos(spawn); - { - float lenAB = length(d); - vec2 dirH = (lenAB > 0.0) ? d / lenAB : vec2(1.0, 0.0); - float fullStep = lenAB / float(numSeg); - spawnYc = max(spawnYc, heightAtWorldPos(spawn + dirH * (fullStep * 0.30))); - spawnYc = max(spawnYc, heightAtWorldPos(spawn - dirH * (fullStep * 0.30))); - spawnYc = max(spawnYc, heightAtWorldPos(spawn + dirH * (fullStep * 0.55))); - spawnYc = max(spawnYc, heightAtWorldPos(spawn - dirH * (fullStep * 0.55))); - spawnYc = max(spawnYc, heightAtWorldPos(spawn + dirH * (fullStep * 0.85))); - spawnYc = max(spawnYc, heightAtWorldPos(spawn - dirH * (fullStep * 0.85))); - } - spawnYc += 0.9; // 0.6 elmos below main ribbon's centerline (which is +1.5) — avoids z-fighting at the junction while keeping the twig visually attached to the trunk. + // 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 @@ -1713,6 +1777,7 @@ local cableFSSrc = [[ uniform sampler2D infoTex; uniform float gameTime; uniform float bakeTime; +uniform float enableFlow; // 1.0 = full bubble pass; 0.0 = static cables (no animation) in DataGS { vec3 worldPos; @@ -1727,8 +1792,77 @@ in DataGS { //__ENGINEUNIFORMBUFFERDEFS__ -const float GROWTH_RATE = 250.0; // elmos/s — must match unsynced GROWTH_RATE -const float WITHER_RATE = 400.0; +// ===================================================================== +// 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 ghost (non-own ally outside LOS): flat dim look, no animation. +const vec3 GHOST_BASE_LO = vec3(0.30); // capT = 0 +const vec3 GHOST_BASE_HI = vec3(0.55); // capT = 1 +const float GHOST_BRANCH_DAMP = 0.65; +const float GHOST_LOS_THRESH = 0.45; +const float GHOST_ALPHA_MAX = 0.55; + +// 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; @@ -1909,22 +2043,20 @@ void main() { vec3 cylNormal = normalize(trueUp * up + perp3D * v); // Own lighting (forward rendered, no engine lighting applies) - float diffuse = max(0.25, dot(cylNormal, normalize(sunDir.xyz))); + 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)), 24.0) * 0.35; + float spec = pow(max(0.0, dot(cylNormal, halfDir)), SPEC_EXP) * SPEC_MAGNITUDE; - // Light-gray cable test: bark and inner are neutral grays, lit only by - // diffuse + bubble glow. Industrial conduit look. + // Bark / inner gray-scale tint by capacity. Industrial conduit look. float capT = clamp(capacity / 100.0, 0.0, 1.0); - vec3 barkColor = vec3(0.55, 0.55, 0.55); - vec3 innerColor = mix(vec3(0.65, 0.65, 0.65), vec3(0.85, 0.85, 0.85), capT); + vec3 innerColor = mix(INNER_COLOR_LO, INNER_COLOR_HI, capT); float innerMix = smoothstep(0.85, 0.15, t); - if (isBranch > 0.5) innerMix *= 0.7; - vec3 baseColor = mix(barkColor, innerColor, innerMix); + 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; @@ -1934,7 +2066,7 @@ void main() { vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); - float fullLOS = smoothstep(0.7, 1.0, losState); + float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); // Enemy cables out of LOS: render as a flat dim ghost reflecting the last // known state (the synced gadget broadcasts every ally team's grid to all @@ -1946,26 +2078,35 @@ void main() { // We early-out here so the bubble layer pass and bark lighting below // don't run for ghosts; they'd be wasted work. float isOwnAlly = gridData.w; - if (isOwnAlly < 0.5 && losState < 0.45) { + if (isOwnAlly < 0.5 && losState < GHOST_LOS_THRESH) { // Neutral grayish ghost — a "remembered" cable, not a live circuit. // Capacity barely tints brightness so thicker grid lines read slightly // brighter without picking up a hue. Branches are a touch dimmer. float capT = clamp(capacity / 100.0, 0.0, 1.0); - vec3 ghostBase = mix(vec3(0.30), vec3(0.55), capT); - if (isBranch > 0.5) ghostBase *= 0.65; + vec3 ghostBase = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT); + if (isBranch > 0.5) ghostBase *= GHOST_BRANCH_DAMP; // Edge falloff so the ribbon edges fade rather than hard-cut, giving - // the ghost a softer "remembered impression" look. + // the ghost a softer "remembered impression" look. Drives alpha too, + // so the silhouette dissolves smoothly instead of hard-clipping at t=0.9. float edgeFade = 1.0 - smoothstep(0.55, 0.90, t); - // Alpha < 1 so the ghost composes against the world (DrawWorldPreUnit - // enables alpha blending). Edge fade also drives alpha so the silhouette - // dissolves smoothly rather than hard-clipping at t=0.9. - float ghostA = 0.55 * edgeFade; - fragColor = vec4(ghostBase * edgeFade, ghostA); + fragColor = vec4(ghostBase * edgeFade, GHOST_ALPHA_MAX * edgeFade); return; } // Apply lighting - vec3 color = baseColor * diffuse + vec3(1.0, 0.95, 0.85) * spec; + 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. // @@ -1977,9 +2118,9 @@ void main() { // 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. - // - Three layered streams of bubbles (big, medium, 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. + // - 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. // @@ -1987,9 +2128,6 @@ void main() { // 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. - const float MAX_SPEED = 110.0; - const float FLOW_REF = 50.0; - const float MIN_TRUNK_W = 3.0; 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 @@ -2010,94 +2148,70 @@ void main() { // `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 = 105.0 / spacingMul; - float spacingB = 48.0 / spacingMul; + float spacingA = SPACING_A / spacingMul; + float spacingB = SPACING_B / spacingMul; // Bubble pass: main ribbon uses two advecting bubble layers; twigs do a - // single synchronized flash (whole twig glows at the same instant, once - // per `FLASH_PERIOD` elmos of phase). All twigs share `phase`, so every - // twig of a cable flashes in lockstep at a rate driven by the main-cable - // speed — no per-twig flow, just a discrete pulse signalling "power - // pulse reached the limb". + // two-stage wave (see CABLE_PROP_SPEED + TWIG_SWEEP_SPEED). float bubbleBody, bubbleSpec, bubbleHalo; if (isBranch > 0.5) { - // Two-stage wavefront: - // 1. CABLE_PROP_SPEED is a FAST virtual wave that sweeps along the - // cable's `along` axis. Twigs at lower spawnAlongMain get "hit" - // earlier, encoding direction-from-root via stagger. - // 2. When the cable wave passes a twig's root, a SLOWER sub-wave - // starts at twig-local 0 and propagates through that twig at - // TWIG_SWEEP_SPEED. This way the inter-twig stagger feels - // snappy while the visible motion *within* each twig stays at - // a comfortable speed. - // Without spawnAlongMain we couldn't decouple these — both speeds - // would be tied to the same propagation rate. - const float CABLE_PROP_SPEED = 400.0; // elmos/sec — fast inter-twig stagger - // Recurrence period: 2800 elmos / 400 elmos/sec = 7 sec between waves. - // Made the wave a sparse "every several seconds" event rather than a - // constant pulse train, so it reads as a periodic energy surge instead - // of nervous flicker. - const float CABLE_PROP_PERIOD = 2800.0; // elmos - const float TWIG_SWEEP_SPEED = 90.0; // elmos/sec — visible motion within a twig - const float PULSE_HW = 5.0; // Gaussian sigma in elmos - - // Elmos along the cable since the wave passed THIS twig's root - // (wraps every CABLE_PROP_PERIOD elmos). Subtracting spawnAlongMain - // from `gameTime * speed` gives a per-twig "time since root was hit" - // in elmos-of-cable-wave-travel. + // 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); - // Convert to seconds, then to twig-local sub-wave position. float subwavePos = TWIG_SWEEP_SPEED * (wavePassedElmos / CABLE_PROP_SPEED); - - // Fragment's twig-local along (0 at root, bLen at tip). float localAlong = along - spawnAlongMain; float d = localAlong - subwavePos; - // No wrap correction needed: when subwavePos overshoots the twig - // length the Gaussian naturally falls to ~0 (fragment is too far - // from the now-passed sub-wave). + // 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 * 0.55; - bubbleBody = intensity * 1.10; - bubbleSpec = intensity * 0.55; - bubbleHalo = intensity * 0.50; + 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, 7.5, v, halfWidthE, 3.7); - vec3 bB = bubbleLayer(along, phase, spacingB, 4.0, v, halfWidthE, 19.1); + 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 * 0.55; + 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), 0.18); - vec3 bubbleColor = mix(grayedGrid, vec3(1.0), 0.15); + 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 — they're emissive - // and shouldn't fade with LOS-darkness; previously the dim was applied - // after bubble composition, which made the glowing balls visibly disappear - // as they crossed dim/shadow regions. Now the bark dims, then bubbles are - // composed at full emissive brightness on top, so they remain visible as - // "lights in the dark". - float dimFactor = mix(0.3, 1.0, smoothstep(0.3, 0.8, losState)); + // 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 the dark green/brown 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. + // - 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 * 0.70; - vec3 bubbleEmissive = bubbleColor * bubbleBody * fullLOS * 1.85; + color += haloColor * bubbleHalo * fullLOS * HALO_WEIGHT; + vec3 bubbleEmissive = bubbleColor * bubbleBody * fullLOS * BODY_WEIGHT; color = max(color, bubbleEmissive); - color += vec3(1.0) * bubbleSpec * fullLOS * 1.10; + color += vec3(1.0) * bubbleSpec * fullLOS * SPEC_WEIGHT; // FULLY OPAQUE output — like lava. No alpha blending. fragColor = vec4(color, 1.0); @@ -2325,6 +2439,7 @@ function gadget:DrawWorldPreUnit() local frameOff = Spring.GetFrameTimeOffset and Spring.GetFrameTimeOffset() or 0 cableShader:SetUniform("gameTime", Spring.GetGameSeconds() + frameOff / GAME_SPEED) cableShader:SetUniform("bakeTime", bubbleBakeTime) + cableShader:SetUniform("enableFlow", flowMode and 1.0 or 0.0) gl.Texture(0, "$info") gl.Texture(1, "$heightmap") @@ -2374,6 +2489,7 @@ function gadget:Initialize() uniformFloat = { gameTime = 0, bakeTime = 0, + enableFlow = flowMode and 1.0 or 0.0, }, }, "Cable Forward Shader") @@ -2387,6 +2503,23 @@ function gadget:Initialize() local data = SYNCED.CableTreePerf if data then drawPerf = data.perf and true or false end end) + gadgetHandler:AddSyncAction("CableTreeFlowMode", function() + local data = SYNCED.CableTreeFlowMode + if data then + local newMode = data.flowMode and true or false + if newMode ~= flowMode then + flowMode = newMode + -- Persist on this (unsynced) side; synced cannot read config. + Spring.SetConfigInt("OverdriveCableFlow", flowMode and 1 or 0) + end + end + end) + -- Bootstrap synced with the persisted setting. Synced defaults to ON; + -- if the user previously turned flow off, we tell synced to switch. + -- (When already ON, this is a no-op on the synced side.) + if not flowMode then + Spring.SendLuaRulesMsg("cabletree:flow:off") + end end function gadget:Shutdown() @@ -2394,6 +2527,7 @@ function gadget:Shutdown() cableVAO = nil gadgetHandler:RemoveSyncAction("CableTreeFull") gadgetHandler:RemoveSyncAction("CableTreePerf") + gadgetHandler:RemoveSyncAction("CableTreeFlowMode") end end -- UNSYNCED From b056ed4fc9169db4c4cfb3b679af6f88dbffc4d9 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 15:43:44 +0200 Subject: [PATCH 39/59] perf madness --- .../Shaders/gfx_overdrive_cables.frag.glsl | 446 +++ .../Shaders/gfx_overdrive_cables.geom.glsl | 477 ++++ .../Shaders/gfx_overdrive_cables.vert.glsl | 29 + LuaRules/Gadgets/gfx_overdrive_cables.lua | 2410 ++++++++--------- LuaUI/Widgets/gfx_overdrive_cables_menu.lua | 82 + scripts/energywind.lua | 54 +- 6 files changed, 2237 insertions(+), 1261 deletions(-) create mode 100644 LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl create mode 100644 LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl create mode 100644 LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl create mode 100644 LuaUI/Widgets/gfx_overdrive_cables_menu.lua 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..8fe5e16d7e --- /dev/null +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -0,0 +1,446 @@ +#version 420 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require + +uniform sampler2D infoTex; +uniform float gameTime; +uniform float bakeTime; +uniform float enableFlow; // 1.0 = full bubble pass; 0.0 = static cables (no animation) + +in DataGS { + vec3 worldPos; + float capacity; + float isBranch; + float width; + vec2 cableUV; + vec2 timeData; + vec4 gridData; + float spawnAlongMain; +}; + +//__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 ghost (non-own ally outside LOS): flat dim look, no animation. +const vec3 GHOST_BASE_LO = vec3(0.30); // capT = 0 +const vec3 GHOST_BASE_HI = vec3(0.55); // capT = 1 +const float GHOST_BRANCH_DAMP = 0.65; +const float GHOST_LOS_THRESH = 0.45; +const float GHOST_ALPHA_MAX = 0.55; + +// 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); +} + +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; + } + + // 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 (needed first for animation gating) + vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; + float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); + float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); + float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); + + // Enemy cables out of LOS: render as a flat dim ghost reflecting the last + // known state (the synced gadget broadcasts every ally team's grid to all + // clients, so we already hold the last-received topology even after LOS + // is lost). Skip live shading + bubble animation — ghost is static so it + // reads as "memory" rather than current activity. Own cables stay live + // (they're always visible to the local viewer). + // + // We early-out here so the bubble layer pass and bark lighting below + // don't run for ghosts; they'd be wasted work. + float isOwnAlly = gridData.w; + if (isOwnAlly < 0.5 && losState < GHOST_LOS_THRESH) { + // Neutral grayish ghost — a "remembered" cable, not a live circuit. + // Capacity barely tints brightness so thicker grid lines read slightly + // brighter without picking up a hue. Branches are a touch dimmer. + float capT = clamp(capacity / 100.0, 0.0, 1.0); + vec3 ghostBase = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT); + if (isBranch > 0.5) ghostBase *= GHOST_BRANCH_DAMP; + // Edge falloff so the ribbon edges fade rather than hard-cut, giving + // the ghost a softer "remembered impression" look. Drives alpha too, + // so the silhouette dissolves smoothly instead of hard-clipping at t=0.9. + float edgeFade = 1.0 - smoothstep(0.55, 0.90, t); + fragColor = vec4(ghostBase * edgeFade, GHOST_ALPHA_MAX * 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..2ea720aa13 --- /dev/null +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -0,0 +1,477 @@ +#version 330 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: require +#extension GL_ARB_gpu_shader5 : 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; + +in DataVS { + vec2 vsWorldXZ; + vec3 vsCableData; + vec4 vsGridData; +} dataIn[]; + +out DataGS { + vec3 worldPos; + float capacity; + float isBranch; + float width; + vec2 cableUV; + vec2 timeData; + vec4 gridData; + float spawnAlongMain; // twig-only: global cableUV.x of the twig's root; 0 for main ribbon. Lets the FS compute twig-local along for sub-wave animation. +}; + +//__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 = 100.0; + +// 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; +float gOutSpawnAlong = 0.0; // set by emitTwig per-twig; main ribbon leaves at 0. + +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; + // (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. +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); + return base + perpAB * 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() { + 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; + + 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); + float seed = a.x * 0.137 + a.y * 0.781 + b.x * 0.293 + b.y * 0.461; + + // 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); // sum |Δslope| as curvature proxy + 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. + float arcDh = cableArcDh(a, d, perpAB, lenAB); + + if (gl_InvocationID == 0) { + emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg, arcDh); + } else { + // 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..43c7a24085 --- /dev/null +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl @@ -0,0 +1,29 @@ +#version 420 +#extension GL_ARB_uniform_buffer_object : require +#extension GL_ARB_shading_language_420pack: 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) + +out gl_PerVertex { + vec4 gl_Position; +}; + +out DataVS { + vec2 vsWorldXZ; + vec3 vsCableData; + vec4 vsGridData; +}; + +void main() { + vsWorldXZ = vertPos; + vsCableData = vertData; + vsGridData = vertGrid; + gl_Position = vec4(0.0); +} diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index d844401f7b..9116615a95 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -18,16 +18,26 @@ function gadget:GetInfo() } end -------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------- - -if gadgetHandler:IsSyncedCode() then +-- 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 ------------------------------------------------------------------------------------- --- SYNCED +-- 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 and sends --- Full or Delta updates to unsynced. Visual progress is unsynced-only. +-- Periodically computes desired spanning tree edges per grid; OnCableTreeFull +-- below consumes the result directly (no sandbox crossover). ------------------------------------------------------------------------------------- local spGetUnitPosition = Spring.GetUnitPosition @@ -167,6 +177,142 @@ local pendingGridDirty = {} -- [gridKey] = { ally, gridID } -- 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 + +-- 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 @@ -184,14 +330,61 @@ do 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). --- cableFlowMode = full bubble animation + per-tick consumer draw reads. --- cableFlowMode = false: static cables, no per-tick reads, no bubbles. --- Persistence happens on the unsynced side (Spring.GetConfigInt is --- unsynced-only); unsynced bootstraps synced via a Lua rules msg on init. -local cableEnabled = true +local cableEnabled = cableDetail ~= DETAIL_OFF local cablePerf = false -local cableFlowMode = true +local cableFlowMode = cableDetail == DETAIL_FULL + +-- 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 @@ -206,12 +399,40 @@ local function GridKey(allyTeamID, gridID) return allyTeamID .. ":" .. gridID end -local function MarkGridDirty(ally, gridID) - if not gridID or gridID <= 0 or not ally then return 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) - if not pendingGridDirty[gk] then - pendingGridDirty[gk] = { ally = ally, gridID = 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. @@ -254,144 +475,500 @@ local function GetNodeDcurrent(unitID, unitDefID) end ------------------------------------------------------------------------------------- --- Per-grid Euclidean MST — Prim's where every pair within visual reach is a --- candidate (no per-pylon range filter). Grid membership is whatever --- unit_mex_overdrive decides; once "these N pylons are one grid", we lay the --- shortest-total-cable spanning tree over them. This produces co-linear chains --- and avoids hub-fan artifacts a long-range pylon would otherwise create. --- The spatial hash still gates candidate pairs to a generous radius so very --- large grids stay sub-quadratic; cell size is set large enough that any --- realistic MST edge falls within a 3x3 cell neighbourhood. +-- 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. ------------------------------------------------------------------------------------- -local SPATIAL_CELL = 2000 -- cell size; 3x3 neighbourhood covers ~4000 elmo pairs -local MST_CANDIDATE_R = 4000 -- hard cap on candidate-pair distance (squared below) - -local function BuildGridMST(allyTeamID, gridID) - local pylons = {} - -- lastGridNum is the authoritative effective-grid map maintained by - -- SyncWithGrid (already accounts for active/inactive state). - for unitID, node in pairs(nodes[allyTeamID]) do - if lastGridNum[unitID] == gridID then - pylons[#pylons + 1] = { - unitID = unitID, x = node.x, z = node.z, - range = node.range, unitDefID = node.unitDefID, - } +-- (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 - local result = {} - if #pylons < 2 then return result 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 - -- Spatial hash bucket pylons by cell. - local cells = {} - for i = 1, #pylons do - local p = pylons[i] - local cx = floor(p.x / SPATIAL_CELL) - local cz = floor(p.z / SPATIAL_CELL) - local ck = cx * 100000 + cz - if not cells[ck] then cells[ck] = {} end - cells[ck][#cells[ck] + 1] = i + -- 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 - -- Neighbour list. In "euclidean" mode every pair within MST_CANDIDATE_R is - -- a candidate (clean visual MST). In "realistic" mode we keep the engine's - -- pylon-range filter (cables only where pylons can actually reach each - -- other) — faithful to physical wiring. - local rSq = MST_CANDIDATE_R * MST_CANDIDATE_R - local euclidean = MST_MODE == "euclidean" - local neighbors = {} - for i = 1, #pylons do - neighbors[i] = {} - local p = pylons[i] - 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 ~= i then - local o = pylons[j] - local dx = p.x - o.x - local dz = p.z - o.z - local distSq = dx * dx + dz * dz - local cap = euclidean and rSq - or ((p.range + o.range) * (p.range + o.range)) - if distSq < cap then - neighbors[i][#neighbors[i] + 1] = j - 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 - -- Root = highest nameplate production (stable across wind/load). + 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, #pylons do - local prod = GetNodePmax(pylons[i].unitDefID) + for i = 1, n do + local prod = GetNodePmax(allyNodes[puid[i]].unitDefID) if prod > bestProd then bestProd = prod; bestRoot = i end end - -- Prim's MST using neighbor lists: O(n * avg_neighbors) + -- 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 - -- Frontier: unvisited nodes adjacent to tree. Track best distance per node. - local bestEdge = {} -- [idx] = { distSq, treeIdx } - for _, j in ipairs(neighbors[bestRoot]) do - local p = pylons[bestRoot] - local o = pylons[j] - local dx = p.x - o.x - local dz = p.z - o.z - bestEdge[j] = { distSq = dx * dx + dz * dz, from = bestRoot } - end - - while treeSize < #pylons do - -- Find frontier node with smallest distance - local bestDistSq = math.huge - local bestJ = nil - for j, be in pairs(bestEdge) do - if not inTree[j] and be.distSq < bestDistSq then - bestDistSq = be.distSq - bestJ = j + 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 - if not bestJ then break end - inTree[bestJ] = true - treeSize = treeSize + 1 + 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 - local parentIdx = bestEdge[bestJ].from - bestEdge[bestJ] = nil - - local p, c = pylons[parentIdx], pylons[bestJ] - local key = EdgeKey(p.unitID, c.unitID) - result[key] = { - parentID = p.unitID, childID = c.unitID, - px = p.x, pz = p.z, cx = c.x, cz = c.z, - } - - -- Update frontier: check neighbors of newly added node - for _, j in ipairs(neighbors[bestJ]) do - if not inTree[j] then - local o = pylons[j] - local nj = pylons[bestJ] - local dx = nj.x - o.x - local dz = nj.z - o.z + 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 - if not bestEdge[j] or distSq < bestEdge[j].distSq then - bestEdge[j] = { distSq = distSq, from = bestJ } + 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 - return result + 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 ------------------------------------------------------------------------------------- @@ -403,8 +980,10 @@ end ------------------------------------------------------------------------------------- local function SyncWithGrid() - -- 1) Drop dead units; mark their last-known grid dirty so its MST is - -- rebuilt without them. + 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 @@ -416,43 +995,169 @@ local function SyncWithGrid() if toRemove then for i = 1, #toRemove do local uid = toRemove[i] - MarkGridDirty(allyTeamID, lastGridNum[uid]) + 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 marks BOTH the old and new - -- grid dirty (the old one because it lost a member, the new one - -- because it gained one). Inactive pylons map to 0 and drop out. + -- 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 - MarkGridDirty(allyTeamID, oldG) - MarkGridDirty(allyTeamID, newG) + 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 - -- 3) Rebuild only dirty grids; everything else stays cached. An empty - -- rebuild result (1 or 0 members → no MST edges) drops the grid from - -- the cache entirely. + -- 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 newMst = BuildGridMST(info.ally, info.gridID) - if next(newMst) then - mstByGrid[gk] = { ally = info.ally, gridID = info.gridID, edges = newMst } + 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 = {} @@ -461,6 +1166,7 @@ local function SyncWithGrid() 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 @@ -482,6 +1188,16 @@ local function SyncWithGrid() 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 ------------------------------------------------------------------------------------- @@ -602,7 +1318,7 @@ local function BuildMpCache() subDmax[u] = did and GetNodeDmax(did) or 0 if did and isWindgenByDef[did] then subWindCount[u] = 1 - subWindBase[u] = spGetUnitRulesParam(u, "minWind") or 0 + subWindBase[u] = GetCachedMinWind(u) subPmaxNonWind[u] = 0 else subWindCount[u] = 0 @@ -641,7 +1357,10 @@ end -- 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 @@ -763,18 +1482,114 @@ local function ComputeMaxPotentials(flowMode) 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 ------------------------------------------------------------------------------------- --- Send state to unsynced. One Full snapshot per ally, only when topology changed. --- Capacity drift between topology changes is ignored (acceptable: cable colour --- only updates when the grid actually mutates). +-- 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 + local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 + local _, _, _, currStrength = Spring.GetWind() + currStrength = currStrength or 0 + local newWindFrac = (windMax > 0) and (currStrength / windMax) or 0 + if newWindFrac < 0 then newWindFrac = 0 elseif newWindFrac > 1 then newWindFrac = 1 end + if math.abs(newWindFrac - 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 @@ -795,41 +1610,61 @@ local function SendAll() pa.cxs[i], pa.czs[i] = edge.cx, edge.cz pa.caps[i] = capacities[key] or 0 pa.flows[i] = flows[key] or 0 - -- Grid efficiency (E/M ratio) is uniform across a grid; read it from - -- the parent end. Negative means "no grid" (sentinel from - -- unit_mex_overdrive); we forward 0 in that case → magenta in shader. - local eff = spGetUnitRulesParam(edge.parentID, "gridefficiency") - or spGetUnitRulesParam(edge.childID, "gridefficiency") or 0 - if eff < 0 then eff = 0 end + -- 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() - -- Fire one message per ally that currently has edges. + -- One snapshot per ally that currently has edges. for ally, pa in pairs(perAlly) do - _G.CableTreeFull = { + 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, - } - SendToUnsynced("CableTreeFull") + }) alliesWithEdges[ally] = true end -- Allies whose last edge just disappeared get one zero-edge snapshot so - -- unsynced clears them; then we forget them. + -- the renderer clears them; then we forget them. for ally in pairs(alliesWithEdges) do if not perAlly[ally] then - _G.CableTreeFull = { + OnCableTreeFull({ allyTeamID = ally, edgeCount = 0, keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, caps = {}, flows = {}, effs = {}, - } - SendToUnsynced("CableTreeFull") + }) 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 + do + local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 + local _, _, _, currStrength = Spring.GetWind() + currStrength = currStrength or 0 + local newWindFrac = (windMax > 0) and (currStrength / windMax) or 0 + if newWindFrac < 0 then newWindFrac = 0 elseif newWindFrac > 1 then newWindFrac = 1 end + lastWindFrac = newWindFrac + end + + if perf then + local tEnd = Spring.GetTimer() + perfStats.binMs = Spring.DiffTimers(tBin1, tBin0) * 1000 + perfStats.dispatchMs = Spring.DiffTimers(tEnd, tBin1) * 1000 + end end ------------------------------------------------------------------------------------- @@ -837,104 +1672,141 @@ end ------------------------------------------------------------------------------------- -- Sends one zero-edge snapshot per ally that currently has cables, so the --- unsynced side clears its geometry. Used when the visualization is toggled --- off so no stale cables linger. +-- 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 - _G.CableTreeFull = { + OnCableTreeFull({ allyTeamID = ally, edgeCount = 0, keys = {}, pxs = {}, pzs = {}, cxs = {}, czs = {}, caps = {}, flows = {}, effs = {}, - } - SendToUnsynced("CableTreeFull") + }) end alliesWithEdges = {} edges = {} topologyDirty = false + -- Reset stability snapshots; on next enable, all edges read as new. + lastSentFlow = {} + lastSentEff = {} + ticksSinceSend = 0 end -function gadget:GameFrame(n) +-- 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() + -- SendAll returns false when its stability check short-circuited. + sentThisTick = (SendAll() ~= false) topologyDirty = false end - -- Synced has no timing API (Spring.GetTimer is unsynced-only, and the - -- sandbox doesn't expose `os`). Just report the edge count from here; - -- the real cost numbers come from the unsynced rebuild log, which - -- captures the heavier path (geometry + VBO upload). - if cablePerf then + if perf then + local tEnd = Spring.GetTimer() local nEdges = 0 for _ in pairs(edges) do nEdges = nEdges + 1 end - Spring.Echo(string.format("[CableTree] sync edges=%d", nEdges)) + 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 --- Tells unsynced whether to gate the FS bubble pass. Synced is the source of --- truth (chat command runs here); unsynced mirrors via SendToUnsynced. -local function PushFlowModeToUnsynced() - _G.CableTreeFlowMode = { flowMode = cableFlowMode } - SendToUnsynced("CableTreeFlowMode") -end - -local function SetFlowMode(on) - if cableFlowMode == on then return end - cableFlowMode = on - PushFlowModeToUnsynced() - -- Force one fresh send so the new mode takes effect immediately rather - -- than waiting for the next topology change (which might never come on - -- a settled grid). - SendAll() - topologyDirty = false +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 — toggle on/off --- /cabletree on / off — explicit --- /cabletree flow on/off — toggle bubble animation + per-tick flow reads --- (the heavy path at 1500+ pylons) --- /cabletree perf — toggle per-cycle timing log --- /cabletree status — print current state +-- /cabletree detail off|noflow|full — set detail level (the menu widget +-- drives this; can also be typed) +-- /cabletree perf — toggle per-cycle timing log +-- /cabletree status — print current state local function CableTreeCmd(cmd, line, words, playerID) local arg = (words and words[1]) or "" - if arg == "" or arg == "toggle" then - cableEnabled = not cableEnabled - if not cableEnabled then ClearAll() end - Spring.Echo("[CableTree] " .. (cableEnabled and "ON" or "OFF")) - elseif arg == "on" then - cableEnabled = true - Spring.Echo("[CableTree] ON") - elseif arg == "off" then - cableEnabled = false - ClearAll() - Spring.Echo("[CableTree] OFF") - elseif arg == "flow" then - local sub = (words and words[2]) or "" - if sub == "on" then SetFlowMode(true) - elseif sub == "off" then SetFlowMode(false) - else SetFlowMode(not cableFlowMode) end - Spring.Echo("[CableTree] flow animation " .. (cableFlowMode and "ON" or "OFF")) + 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 == "perf" then cablePerf = not cablePerf - _G.CableTreePerf = { perf = cablePerf } - SendToUnsynced("CableTreePerf") 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 Spring.Echo(string.format( - "[CableTree] enabled=%s flow=%s perf=%s edges=%d", - tostring(cableEnabled), tostring(cableFlowMode), - tostring(cablePerf), nEdges)) + "[CableTree] detail=%s perf=%s edges=%d", + DETAIL_NAMES[cableDetail], tostring(cablePerf), nEdges)) else - Spring.Echo("[CableTree] usage: /cabletree [on|off|toggle|flow on/off|perf|status]") + Spring.Echo("[CableTree] usage: /cabletree detail off|noflow|full | perf | status") end return true end @@ -954,6 +1826,13 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam) } 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) @@ -968,9 +1847,14 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) local _, _, _, _, _, oldAlly = Spring.GetTeamInfo(oldTeam, false) if not newAlly or not oldAlly then return end if newAlly ~= oldAlly then - -- Old ally's grid loses a member: dirty it before we forget which - -- grid this unit was in. - MarkGridDirty(oldAlly, lastGridNum[unitID]) + -- 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 @@ -984,25 +1868,27 @@ function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) } 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 --- Unsynced bootstraps synced with the player's persisted flow setting via --- Spring.SendLuaRulesMsg (see unsynced gadget:Initialize). Format: --- "cabletree:flow:on" / "cabletree:flow:off". We only react to that exact --- prefix; other messages pass through untouched. -function gadget:RecvLuaMsg(msg, playerID) - if msg == "cabletree:flow:on" then - SetFlowMode(true) - elseif msg == "cabletree:flow:off" then - SetFlowMode(false) - end -end - -function gadget:Initialize() - GG.CableTree = { nodes = nodes, edges = edges } +-- 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 @@ -1015,19 +1901,22 @@ function gadget:Initialize() } 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 ------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------- - -else -- UNSYNCED - -------------------------------------------------------------------------------------- --- UNSYNCED — Shader-based cable rendering via DrawWorldPreUnit --- Cables are drawn as quad strips projected onto ground height. --- Fragment shader: procedural organic texture, LOS-gated animation. +-- 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 @@ -1142,11 +2031,8 @@ local geomCache = { local cableShader -- forward shader for cable rendering local cableVAO -- live cable geometry local numCableVerts = 0 -local drawPerf = false -- toggled by the synced /cabletree perf command --- Mirrors synced cableFlowMode. Drives the FS `enableFlow` uniform; both --- sides initialise from the same Spring config key so the toggle survives --- reloads even before the synced side gets a chance to push. -local flowMode = (Spring.GetConfigInt("OverdriveCableFlow", 1) or 1) ~= 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 @@ -1257,966 +2143,15 @@ end -- cylinder normal, plus traveling energy pulses gated by LOS ($info). ------------------------------------------------------------------------------------- --- Pass-through VS: each cable is a single GL_LINES primitive (2 vertices, --- both carrying the same per-edge attributes). The geometry shader then --- 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. -local cableVSSrc = [[ -#version 420 -#extension GL_ARB_uniform_buffer_object : require -#extension GL_ARB_shading_language_420pack: require - -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) - -out gl_PerVertex { - vec4 gl_Position; -}; - -out DataVS { - vec2 vsWorldXZ; - vec3 vsCableData; - vec4 vsGridData; -}; - -void main() { - vsWorldXZ = vertPos; - vsCableData = vertData; - vsGridData = vertGrid; - gl_Position = vec4(0.0); -} -]] - --- (dead-code block removed) - --- 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. -local cableGSSrc = [[ -#version 330 -#extension GL_ARB_uniform_buffer_object : require -#extension GL_ARB_shading_language_420pack: require -#extension GL_ARB_gpu_shader5 : require - -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; - -in DataVS { - vec2 vsWorldXZ; - vec3 vsCableData; - vec4 vsGridData; -} dataIn[]; - -out DataGS { - vec3 worldPos; - float capacity; - float isBranch; - float width; - vec2 cableUV; - vec2 timeData; - vec4 gridData; - float spawnAlongMain; // twig-only: global cableUV.x of the twig's root; 0 for main ribbon. Lets the FS compute twig-local along for sub-wave animation. -}; - -//__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 = 100.0; - -// 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; -float gOutSpawnAlong = 0.0; // set by emitTwig per-twig; main ribbon leaves at 0. - -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; - // (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. -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); - return base + perpAB * 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() { - 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; - - 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); - float seed = a.x * 0.137 + a.y * 0.781 + b.x * 0.293 + b.y * 0.461; - - // 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); // sum |Δslope| as curvature proxy - 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. - float arcDh = cableArcDh(a, d, perpAB, lenAB); - - if (gl_InvocationID == 0) { - emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg, arcDh); - } else { - // 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); - } -} -]] - -local cableFSSrc = [[ -#version 420 -#extension GL_ARB_uniform_buffer_object : require -#extension GL_ARB_shading_language_420pack: require - -uniform sampler2D infoTex; -uniform float gameTime; -uniform float bakeTime; -uniform float enableFlow; // 1.0 = full bubble pass; 0.0 = static cables (no animation) - -in DataGS { - vec3 worldPos; - float capacity; - float isBranch; - float width; - vec2 cableUV; - vec2 timeData; - vec4 gridData; - float spawnAlongMain; -}; - -//__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 ghost (non-own ally outside LOS): flat dim look, no animation. -const vec3 GHOST_BASE_LO = vec3(0.30); // capT = 0 -const vec3 GHOST_BASE_HI = vec3(0.55); // capT = 1 -const float GHOST_BRANCH_DAMP = 0.65; -const float GHOST_LOS_THRESH = 0.45; -const float GHOST_ALPHA_MAX = 0.55; - -// 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); -} - -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; - } - - // 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 (needed first for animation gating) - vec2 losUV = clamp(worldPos.xz, vec2(0.0), mapSize.xy) / mapSize.zw; - float losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); - float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); - float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); - - // Enemy cables out of LOS: render as a flat dim ghost reflecting the last - // known state (the synced gadget broadcasts every ally team's grid to all - // clients, so we already hold the last-received topology even after LOS - // is lost). Skip live shading + bubble animation — ghost is static so it - // reads as "memory" rather than current activity. Own cables stay live - // (they're always visible to the local viewer). - // - // We early-out here so the bubble layer pass and bark lighting below - // don't run for ghosts; they'd be wasted work. - float isOwnAlly = gridData.w; - if (isOwnAlly < 0.5 && losState < GHOST_LOS_THRESH) { - // Neutral grayish ghost — a "remembered" cable, not a live circuit. - // Capacity barely tints brightness so thicker grid lines read slightly - // brighter without picking up a hue. Branches are a touch dimmer. - float capT = clamp(capacity / 100.0, 0.0, 1.0); - vec3 ghostBase = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT); - if (isBranch > 0.5) ghostBase *= GHOST_BRANCH_DAMP; - // Edge falloff so the ribbon edges fade rather than hard-cut, giving - // the ghost a softer "remembered impression" look. Drives alpha too, - // so the silhouette dissolves smoothly instead of hard-clipping at t=0.9. - float edgeFade = 1.0 - smoothstep(0.55, 0.90, t); - fragColor = vec4(ghostBase * edgeFade, GHOST_ALPHA_MAX * 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); -} -]] +-- 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 @@ -2246,15 +2181,16 @@ 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. -local function OnCableTreeFull() - local data = SYNCED.CableTreeFull +-- 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 = drawPerf and Spring.GetTimer() or nil + local tStart = cablePerf and Spring.GetTimer() or nil local frame = Spring.GetGameFrame() local existing = edgesByAllyTeam[ally] or {} @@ -2319,11 +2255,11 @@ local function OnCableTreeFull() end edgesByAllyTeam[ally] = existing - local tDiff = drawPerf and Spring.GetTimer() or nil + local tDiff = cablePerf and Spring.GetTimer() or nil RebuildRenderEdges() needsRebuild = true - if drawPerf then + if cablePerf then local tEnd = Spring.GetTimer() Spring.Echo(string.format( "[CableTree] OnCableTreeFull: diff=%.2f ms rebuildIdx=%.2f ms edges=%d", @@ -2338,7 +2274,7 @@ end ------------------------------------------------------------------------------------- local function RebuildVBO() - local tStart = drawPerf and Spring.GetTimer() or nil + 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`. @@ -2352,7 +2288,7 @@ local function RebuildVBO() end end - local tGen0 = drawPerf and Spring.GetTimer() or nil + local tGen0 = cablePerf and Spring.GetTimer() or nil local verts, vertCount = GenerateOrganicTree() if vertCount == 0 then numCableVerts = 0 @@ -2371,14 +2307,14 @@ local function RebuildVBO() { id = 1, name = "vertData", size = 3 }, -- (capacity, appearTime, witherTime) { id = 2, name = "vertGrid", size = 4 }, -- (efficiency, flow E/s, bubble phase elmos, isOwnAlly) }) - local tUp0 = drawPerf and Spring.GetTimer() or nil + 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 drawPerf then + 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", @@ -2399,7 +2335,12 @@ end local WITHER_HOLD_FRAMES = 8 * GAME_SPEED function gadget:GameFrame(n) - -- Drop fully-withered edges so geometry doesn't grow unboundedly. + -- 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. local dropped = false for ally, edges in pairs(edgesByAllyTeam) do for k, e in pairs(edges) do @@ -2415,11 +2356,12 @@ function gadget:GameFrame(n) geomCache.valid = false end - -- 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. + -- 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 @@ -2439,7 +2381,7 @@ function gadget:DrawWorldPreUnit() local frameOff = Spring.GetFrameTimeOffset and Spring.GetFrameTimeOffset() or 0 cableShader:SetUniform("gameTime", Spring.GetGameSeconds() + frameOff / GAME_SPEED) cableShader:SetUniform("bakeTime", bubbleBakeTime) - cableShader:SetUniform("enableFlow", flowMode and 1.0 or 0.0) + cableShader:SetUniform("enableFlow", cableFlowMode and 1.0 or 0.0) gl.Texture(0, "$info") gl.Texture(1, "$heightmap") @@ -2489,7 +2431,7 @@ function gadget:Initialize() uniformFloat = { gameTime = 0, bakeTime = 0, - enableFlow = flowMode and 1.0 or 0.0, + enableFlow = cableFlowMode and 1.0 or 0.0, }, }, "Cable Forward Shader") @@ -2498,36 +2440,12 @@ function gadget:Initialize() gadgetHandler:RemoveGadget() return end - gadgetHandler:AddSyncAction("CableTreeFull", OnCableTreeFull) - gadgetHandler:AddSyncAction("CableTreePerf", function() - local data = SYNCED.CableTreePerf - if data then drawPerf = data.perf and true or false end - end) - gadgetHandler:AddSyncAction("CableTreeFlowMode", function() - local data = SYNCED.CableTreeFlowMode - if data then - local newMode = data.flowMode and true or false - if newMode ~= flowMode then - flowMode = newMode - -- Persist on this (unsynced) side; synced cannot read config. - Spring.SetConfigInt("OverdriveCableFlow", flowMode and 1 or 0) - end - end - end) - -- Bootstrap synced with the persisted setting. Synced defaults to ON; - -- if the user previously turned flow off, we tell synced to switch. - -- (When already ON, this is a no-op on the synced side.) - if not flowMode then - Spring.SendLuaRulesMsg("cabletree:flow:off") - end + -- Topology side: register chat command + scan existing pylons. + InitTopology() end function gadget:Shutdown() if cableShader then cableShader:Finalize() end cableVAO = nil - gadgetHandler:RemoveSyncAction("CableTreeFull") - gadgetHandler:RemoveSyncAction("CableTreePerf") - gadgetHandler:RemoveSyncAction("CableTreeFlowMode") end -end -- UNSYNCED diff --git a/LuaUI/Widgets/gfx_overdrive_cables_menu.lua b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua new file mode 100644 index 0000000000..e51a63d522 --- /dev/null +++ b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua @@ -0,0 +1,82 @@ +-------------------------------------------------------------------------------- +-- 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 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 + +options_path = 'Settings/Graphics/Game Graphics' +options_order = { 'cabletree_detail' } + +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, + }, +} + +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() + -- And ensure the gadget agrees with whatever was saved (idempotent — + -- the gadget's SetDetailLevel returns early if level is unchanged). + Spring.SendCommands("luarules cabletree detail " .. options.cabletree_detail.value) +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 } +end + +function widget:SetConfigData(data) + if data and data.value and LEVEL_BY_KEY[data.value] then + options.cabletree_detail.value = data.value + end +end diff --git a/scripts/energywind.lua b/scripts/energywind.lua index cd9bf793c1..b06971c6f1 100644 --- a/scripts/energywind.lua +++ b/scripts/energywind.lua @@ -8,12 +8,12 @@ local smokePiece = {base} local hpi = math.pi*0.5 local UPDATE_PERIOD = 1000 -local BUILD_PERIOD = 500 local turnSpeed = math.rad(20) local waterFanSpin = math.rad(30) local SIG_ANIM = 1 +local SIG_WIND = 2 local isWind, baseWind, rangeWind local rand = math.random @@ -40,21 +40,16 @@ local function BobTidal() end local oldWindStrength, oldWindHeading -function SpinWind() +local function SpinWind() + SetSignalMask(SIG_WIND) while true do - if select(5, Spring.GetUnitHealth(unitID)) < 1 then - oldWindStrength = nil - StopSpin(fan, z_axis) - Sleep(BUILD_PERIOD) - else - if GG.WindStrength and ((oldWindStrength ~= GG.WindStrength) or (oldWindHeading ~= GG.WindHeading)) then - oldWindStrength, oldWindHeading = GG.WindStrength, GG.WindHeading - local st = baseWind + (GG.WindStrength or 0)*rangeWind - Spin(fan, z_axis, -st*(0.94 + 0.08*rand())) - Turn(cradle, y_axis, GG.WindHeading - baseDirection + math.pi, turnSpeed) - end - Sleep(UPDATE_PERIOD + 200*rand()) + if GG.WindStrength and ((oldWindStrength ~= GG.WindStrength) or (oldWindHeading ~= GG.WindHeading)) then + oldWindStrength, oldWindHeading = GG.WindStrength, GG.WindHeading + local st = baseWind + (GG.WindStrength or 0)*rangeWind + Spin(fan, z_axis, -st*(0.94 + 0.08*rand())) + Turn(cradle, y_axis, GG.WindHeading - baseDirection + math.pi, turnSpeed) end + Sleep(UPDATE_PERIOD + 200*rand()) if GG.Wind_SpinDisabled then StopSpin(fan, z_axis) @@ -63,10 +58,39 @@ function SpinWind() end end +-- Started/stopped via script.Activate/script.Deactivate (since the unit def has +-- activateWhenBuilt=true). Avoids a per-second Spring.GetUnitHealth poll on every +-- windmill just to detect build completion. EMP/disarm also stops the spin via +-- engine-driven Deactivate, matching the behaviour of other ZK animated buildings. +function script.Activate() + if not isWind then + return + end + Signal(SIG_WIND) + oldWindStrength, oldWindHeading = nil, nil + StartThread(SpinWind) +end + +function script.Deactivate() + if not isWind then + return + end + Signal(SIG_WIND) + StopSpin(fan, z_axis) +end + function InitializeWind() isWind, baseWind, rangeWind = GG.SetupWindmill(unitID) if isWind then - StartThread(SpinWind) + -- Spin starts on script.Activate (engine fires it when build completes, + -- because the unit def sets activateWhenBuilt=true). + -- For the /windanim debug toggle re-init path, kick the thread now if the + -- unit is already built and active. + if Spring.GetUnitIsActive(unitID) then + Signal(SIG_WIND) + oldWindStrength, oldWindHeading = nil, nil + StartThread(SpinWind) + end else StartThread(BobTidal) Hide(base) From 524e6c9f2e6bfc9960b50f876115386c55b9bfb4 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 15:51:13 +0200 Subject: [PATCH 40/59] fix menu entry --- LuaUI/Widgets/gfx_overdrive_cables_menu.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LuaUI/Widgets/gfx_overdrive_cables_menu.lua b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua index e51a63d522..4afc68ec9a 100644 --- a/LuaUI/Widgets/gfx_overdrive_cables_menu.lua +++ b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua @@ -35,7 +35,7 @@ local function readCurrentDetailKey() return KEY_BY_LEVEL[v] or 'full' end -options_path = 'Settings/Graphics/Game Graphics' +options_path = 'Settings/Graphics/Overdrive Cables' options_order = { 'cabletree_detail' } options = { From a39360c1b008dc7dbaee21ff2371b92eaeb93fb3 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 16:15:38 +0200 Subject: [PATCH 41/59] fix flow --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 56 +++++++++++++++-------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 9116615a95..5f2938cde2 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1201,16 +1201,29 @@ local function SyncWithGrid() 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: +-- Per edge: +-- capacity (visual cable thickness) = max(min(sP, oDmax), min(oP, sDmax)) +-- over nameplate Pmax / Dmax. Min-cut. +-- flow (visual bubble rate) = signed subtree-side net surplus +-- (sPcur − sDcur). Conservation at every +-- interior node holds automatically; the +-- global imbalance is absorbed/supplied +-- by team storage at the DFS root. +-- These are different quantities. Capacity is the upper bound on what the +-- cable could ever carry; flow is what is physically moving right now. Using +-- max-of-min-cuts for *flow* (the previous formulation) misroutes edges in +-- mixed-surplus configurations — e.g. a junction whose three subtrees each +-- have positive surplus would render as three inflows with no outflow, energy +-- stuck at the node — because min-cut picks the larger of two simultaneously +-- feasible flows rather than the actual net imbalance. +-- +-- 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. +-- 2. Per edge, otherSide = total − subtreeSide; capacity uses min-cut. +-- With ∞ mex draw the capacity collapses: when both sides have a mex, +-- capacity becomes max(sP, oP). When only one side has a mex, capacity = +-- producer-side Pmax. Voltage-only cuts use the (finite) sum of neededlink +-- thresholds. ------------------------------------------------------------------------------------- -- Build the topology-stable mpCache: adjacency, DFS order, parentInTree, @@ -1433,18 +1446,25 @@ local function ComputeMaxPotentials(flowMode) local flow, flowSrcSubtree if flowMode then - local totalPcur, totalDcur = subPcur[r], subDcur[r] + -- Real flow on a tree edge = signed net surplus of the subtree side. + -- Conservation at every interior node holds automatically: for any + -- node v with incident subtree-cuts, sum of signed edge flows equals + -- v's own (Pcur − Dcur), with the global imbalance absorbed/supplied + -- by team storage (conceptually a virtual sink/source at the DFS + -- root). The previous max-of-min-cuts formulation gave *capacity*, + -- not flow, and misrouted edges in mixed-surplus configurations: + -- e.g. a degree-3 junction whose three subtrees each have positive + -- surplus would render as 3 inflows with 0 outflow (net +N "stuck" + -- at the junction), violating conservation. 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 + local subtreeSurplus = sPc - sDc + if subtreeSurplus > 0 then + flow, flowSrcSubtree = subtreeSurplus, true + elseif subtreeSurplus < 0 then + flow, flowSrcSubtree = -subtreeSurplus, false else - flow, flowSrcSubtree = flowBA, false + flow, flowSrcSubtree = 0, potentialSrcSubtree end - if flow < 0 then flow = 0 end - if flow <= 0 then flowSrcSubtree = potentialSrcSubtree end else flow, flowSrcSubtree = 0, potentialSrcSubtree end From 5ab217e5eba6a0c5535cb468065b6ab30d9022d4 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 16:30:20 +0200 Subject: [PATCH 42/59] surp2 --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 51 ++++++++++------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 5f2938cde2..4e475e95dd 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1204,18 +1204,13 @@ end -- Per edge: -- capacity (visual cable thickness) = max(min(sP, oDmax), min(oP, sDmax)) -- over nameplate Pmax / Dmax. Min-cut. --- flow (visual bubble rate) = signed subtree-side net surplus --- (sPcur − sDcur). Conservation at every --- interior node holds automatically; the --- global imbalance is absorbed/supplied --- by team storage at the DFS root. --- These are different quantities. Capacity is the upper bound on what the --- cable could ever carry; flow is what is physically moving right now. Using --- max-of-min-cuts for *flow* (the previous formulation) misroutes edges in --- mixed-surplus configurations — e.g. a junction whose three subtrees each --- have positive surplus would render as three inflows with no outflow, energy --- stuck at the node — because min-cut picks the larger of two simultaneously --- feasible flows rather than the actual net imbalance. +-- flow (visual bubble rate) = max-of-min-cuts over current Pcur/Dcur. +-- Both quantities are min-cut over the partition (subtree | rest); capacity +-- uses static nameplate, flow uses live consumption. A subtree's signed net +-- surplus is NOT used for flow — that would attribute phantom flow toward +-- the DFS root for energy that physically goes to team storage and never +-- traverses any wire. Team storage is intentionally invisible: producer-rich +-- regions show low throughput, not artificial outflow. -- -- Two passes per tree: -- 1. Post-order DFS aggregates subtreePmax / subtreeDmax per child edge. @@ -1446,25 +1441,25 @@ local function ComputeMaxPotentials(flowMode) local flow, flowSrcSubtree if flowMode then - -- Real flow on a tree edge = signed net surplus of the subtree side. - -- Conservation at every interior node holds automatically: for any - -- node v with incident subtree-cuts, sum of signed edge flows equals - -- v's own (Pcur − Dcur), with the global imbalance absorbed/supplied - -- by team storage (conceptually a virtual sink/source at the DFS - -- root). The previous max-of-min-cuts formulation gave *capacity*, - -- not flow, and misrouted edges in mixed-surplus configurations: - -- e.g. a degree-3 junction whose three subtrees each have positive - -- surplus would render as 3 inflows with 0 outflow (net +N "stuck" - -- at the junction), violating conservation. + -- Min-cut over current Pcur/Dcur: each edge shows the saturating + -- flow given local production/draw bottlenecks, not conservation + -- flow under a virtual storage sink. This is what a cable can + -- physically carry right now; team storage soaking up surplus is + -- intentionally invisible (storage isn't a node, so producer-rich + -- regions correctly show *less* throughput, not phantom outflow + -- toward an arbitrary DFS root). + local totalPcur, totalDcur = subPcur[r], subDcur[r] local sPc, sDc = subPcur[cid], subDcur[cid] - local subtreeSurplus = sPc - sDc - if subtreeSurplus > 0 then - flow, flowSrcSubtree = subtreeSurplus, true - elseif subtreeSurplus < 0 then - flow, flowSrcSubtree = -subtreeSurplus, false + 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 = 0, potentialSrcSubtree + 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 From 1c67c9acce82ffcfb6db92f775ee1310a38194f1 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 17:35:14 +0200 Subject: [PATCH 43/59] revert to min-cut + fix wind --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 69 ++++++++--------------- 1 file changed, 25 insertions(+), 44 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 4e475e95dd..43bfdb2836 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1201,24 +1201,16 @@ local function SyncWithGrid() end ------------------------------------------------------------------------------------- --- Per edge: --- capacity (visual cable thickness) = max(min(sP, oDmax), min(oP, sDmax)) --- over nameplate Pmax / Dmax. Min-cut. --- flow (visual bubble rate) = max-of-min-cuts over current Pcur/Dcur. --- Both quantities are min-cut over the partition (subtree | rest); capacity --- uses static nameplate, flow uses live consumption. A subtree's signed net --- surplus is NOT used for flow — that would attribute phantom flow toward --- the DFS root for energy that physically goes to team storage and never --- traverses any wire. Team storage is intentionally invisible: producer-rich --- regions show low throughput, not artificial outflow. --- --- Two passes per tree: +-- 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 uses min-cut. --- With ∞ mex draw the capacity collapses: when both sides have a mex, --- capacity becomes max(sP, oP). When only one side has a mex, capacity = --- producer-side Pmax. Voltage-only cuts use the (finite) sum of neededlink --- thresholds. +-- 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, @@ -1381,17 +1373,22 @@ local function ComputeMaxPotentials(flowMode) local subWindCount = mpCache.subWindCount local subWindBase = mpCache.subWindBase - -- Per-tick wind globals: one read each, then everything is arithmetic. + -- 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 _, _, _, currStrength = Spring.GetWind() - currStrength = currStrength or 0 - local windFrac = (windMax > 0) and (currStrength / windMax) or 0 + local windFrac = Spring.GetGameRulesParam("WindStrength") or 0 if windFrac < 0 then windFrac = 0 elseif windFrac > 1 then windFrac = 1 end - -- subPcur derived directly from cached aggregates — no per-node Pcur - -- read. Wind: linear-in-strength sum collapses to - -- (1-f)*base + f*windMax*N. Non-wind generators in MST are assumed to - -- be at nameplate (inactive ones have gridID=0 and aren't cached). + -- subPcur from cached aggregates: 0 per-pylon reads. subPcur = {} for i = 1, #order do local u = order[i] @@ -1399,10 +1396,7 @@ local function ComputeMaxPotentials(flowMode) + subPmaxNonWind[u] end - -- subDcur DOES still need per-node reads — mex draw and turret - -- consumption fluctuate per tick. We restrict reads to nodes whose - -- def can possibly draw (mexes, voltage units, builders); pure - -- generators (windmills/solar/fusion) and range-only pylons stay at 0. + -- Consumer reads still per-tick (mex draw / builder energyUse fluctuate). subDcur = {} for i = 1, #order do local u = order[i] @@ -1441,13 +1435,6 @@ local function ComputeMaxPotentials(flowMode) local flow, flowSrcSubtree if flowMode then - -- Min-cut over current Pcur/Dcur: each edge shows the saturating - -- flow given local production/draw bottlenecks, not conservation - -- flow under a virtual storage sink. This is what a cable can - -- physically carry right now; team storage soaking up surplus is - -- intentionally invisible (storage isn't a node, so producer-rich - -- regions correctly show *less* throughput, not phantom outflow - -- toward an arbitrary DFS root). local totalPcur, totalDcur = subPcur[r], subDcur[r] local sPc, sDc = subPcur[cid], subDcur[cid] local oPc, oDc = totalPcur - sPc, totalDcur - sDc @@ -1542,10 +1529,7 @@ local function ConsumersOrWindChanged() return true end end - local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 - local _, _, _, currStrength = Spring.GetWind() - currStrength = currStrength or 0 - local newWindFrac = (windMax > 0) and (currStrength / windMax) or 0 + local newWindFrac = Spring.GetGameRulesParam("WindStrength") or 0 if newWindFrac < 0 then newWindFrac = 0 elseif newWindFrac > 1 then newWindFrac = 1 end if math.abs(newWindFrac - lastWindFrac) > 0.05 then return true end return false @@ -1667,10 +1651,7 @@ local function SendAll() lastConsumerDcur[uid] = GetNodeDcurrent(uid, did) end do - local windMax = Spring.GetGameRulesParam("WindMax") or 2.5 - local _, _, _, currStrength = Spring.GetWind() - currStrength = currStrength or 0 - local newWindFrac = (windMax > 0) and (currStrength / windMax) or 0 + local newWindFrac = Spring.GetGameRulesParam("WindStrength") or 0 if newWindFrac < 0 then newWindFrac = 0 elseif newWindFrac > 1 then newWindFrac = 1 end lastWindFrac = newWindFrac end From b0122c2ba7fdca64e9713006d6c7bd7dc2dc7411 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 17:42:41 +0200 Subject: [PATCH 44/59] max thickness is for singu power --- LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl | 2 +- LuaRules/Gadgets/gfx_overdrive_cables.lua | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl index 2ea720aa13..6323f1e114 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -78,7 +78,7 @@ 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 = 100.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; diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 43bfdb2836..a4b5d657d6 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1942,7 +1942,9 @@ VFS.Include(luaShaderDir .. "instancevbotable.lua") local MIN_TRUNK_WIDTH = 3 local MAX_TRUNK_WIDTH = 12 -local MAX_CAPACITY_REF = 100 +-- 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 @@ -1978,7 +1980,7 @@ 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 = 100.0 +local BUBBLE_CAP_REF = 225.0 -- mirror of MAX_CAPACITY_REF (one singu) local function widthOfCapacity(cap) local t = (cap or 0) / BUBBLE_CAP_REF From 735e650995743a5ceb3df8fdec08247ec590c684 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 17:51:18 +0200 Subject: [PATCH 45/59] cleanup: remove debug bridge, revert energywind, drop stray blank lines - Drop LuaUI/Widgets/dbg_claude_bridge.lua (debug-only TCP harness, kept unstaged in master working tree). - Revert scripts/energywind.lua to master (windmill spin refactor moved to its own branch windmill_activate_refactor). - Restore LuaRules/gadgets.lua and LuaUI/cawidgets.lua to master to drop unintentional blank-line additions. --- LuaRules/gadgets.lua | 3 - LuaUI/Widgets/dbg_claude_bridge.lua | 467 ---------------------------- LuaUI/cawidgets.lua | 1 - scripts/energywind.lua | 54 +--- 4 files changed, 15 insertions(+), 510 deletions(-) delete mode 100644 LuaUI/Widgets/dbg_claude_bridge.lua diff --git a/LuaRules/gadgets.lua b/LuaRules/gadgets.lua index 09d425bded..42c2f29464 100644 --- a/LuaRules/gadgets.lua +++ b/LuaRules/gadgets.lua @@ -2273,9 +2273,6 @@ function gadgetHandler:DrawWorldRefraction() end - - - function gadgetHandler:DrawScreenEffects(vsx, vsy) tracy.ZoneBeginN("G:DrawScreenEffects") for _,g in r_ipairs(self.DrawScreenEffectsList) do diff --git a/LuaUI/Widgets/dbg_claude_bridge.lua b/LuaUI/Widgets/dbg_claude_bridge.lua deleted file mode 100644 index 356cf154c1..0000000000 --- a/LuaUI/Widgets/dbg_claude_bridge.lua +++ /dev/null @@ -1,467 +0,0 @@ --------------------------------------------------------------------------------- --------------------------------------------------------------------------------- - -function widget:GetInfo() - return { - name = "Claude Bridge", - desc = "TCP bridge for external tooling: exec lua, run Spring console commands, stream Spring.Echo. Listens on 127.0.0.1:8200.", - author = "Licho + Claude", - date = "2026-04-29", - license = "GPLv2", - layer = 0, - enabled = true, - } -end - --------------------------------------------------------------------------------- --- Pre-flight: LuaSocket must be enabled in springsettings.cfg. --------------------------------------------------------------------------------- - -if not (Spring.GetConfigInt("LuaSocketEnabled", 0) == 1) then - Spring.Echo("[ClaudeBridge] LuaSocketEnabled=0 - widget inactive. Add 'LuaSocketEnabled = 1' and 'TCPAllowListen = 127.0.0.1:8200' to springsettings.cfg.") - return false -end - -local socket = socket -if not socket then - Spring.Echo("[ClaudeBridge] socket library not available") - return false -end - --------------------------------------------------------------------------------- --- Minimal JSON (inline; LuaRules/Utilities/json.lua relies on _G which the --- LuaUI widget sandbox does not expose). --------------------------------------------------------------------------------- - -local _byte, _sub, _format, _gsub, _char = string.byte, string.sub, string.format, string.gsub, string.char - -local function encStr(s) - s = _gsub(s, "\\", "\\\\") - s = _gsub(s, "\"", "\\\"") - s = _gsub(s, "\n", "\\n") - s = _gsub(s, "\r", "\\r") - s = _gsub(s, "\t", "\\t") - s = _gsub(s, "[%z\1-\31\127]", function(c) return _format("\\u%04x", _byte(c)) end) - return "\"" .. s .. "\"" -end - -local jsonEncode -jsonEncode = function(v) - local t = type(v) - if v == nil then return "null" end - if t == "boolean" then return v and "true" or "false" end - if t == "number" then - if v ~= v or v == math.huge or v == -math.huge then return "null" end - return tostring(v) - end - if t == "string" then return encStr(v) end - if t == "table" then - local n = #v - local cnt = 0 - for _ in pairs(v) do cnt = cnt + 1 end - local isArr = (n > 0 and cnt == n) - if isArr then - local parts = {} - for i = 1, n do parts[i] = jsonEncode(v[i]) end - return "[" .. table.concat(parts, ",") .. "]" - end - if cnt == 0 then return "{}" end - local parts = {} - for k, val in pairs(v) do - parts[#parts + 1] = encStr(tostring(k)) .. ":" .. jsonEncode(val) - end - return "{" .. table.concat(parts, ",") .. "}" - end - return "null" -end - -local jsonDecodeValue - -local function skipWs(s, i) - while true do - local c = _byte(s, i) - if c == 32 or c == 9 or c == 10 or c == 13 then i = i + 1 - else return i end - end -end - -local function decodeStr(s, i) - i = i + 1 - local out = {} - while true do - local c = _byte(s, i) - if not c then error("unterminated string") end - if c == 34 then return table.concat(out), i + 1 end - if c == 92 then - local n = _byte(s, i + 1) - if n == 110 then out[#out + 1] = "\n" - elseif n == 114 then out[#out + 1] = "\r" - elseif n == 116 then out[#out + 1] = "\t" - elseif n == 98 then out[#out + 1] = "\b" - elseif n == 102 then out[#out + 1] = "\f" - elseif n == 34 then out[#out + 1] = "\"" - elseif n == 47 then out[#out + 1] = "/" - elseif n == 92 then out[#out + 1] = "\\" - elseif n == 117 then - local code = tonumber(_sub(s, i + 2, i + 5), 16) - if code and code < 128 then out[#out + 1] = _char(code) - else out[#out + 1] = "?" end - i = i + 4 - else error("bad escape") end - i = i + 2 - else - out[#out + 1] = _sub(s, i, i) - i = i + 1 - end - end -end - -local function decodeNum(s, i) - local j = i - while true do - local c = _byte(s, j) - if c and ((c >= 48 and c <= 57) or c == 45 or c == 43 or c == 46 or c == 101 or c == 69) then - j = j + 1 - else break end - end - return tonumber(_sub(s, i, j - 1)), j -end - -local function decodeArr(s, i) - i = skipWs(s, i + 1) - local out = {} - if _byte(s, i) == 93 then return out, i + 1 end - while true do - local v - v, i = jsonDecodeValue(s, i) - out[#out + 1] = v - i = skipWs(s, i) - local c = _byte(s, i) - if c == 44 then i = skipWs(s, i + 1) - elseif c == 93 then return out, i + 1 - else error("expected , or ]") end - end -end - -local function decodeObj(s, i) - i = skipWs(s, i + 1) - local out = {} - if _byte(s, i) == 125 then return out, i + 1 end - while true do - if _byte(s, i) ~= 34 then error("expected key string") end - local k - k, i = decodeStr(s, i) - i = skipWs(s, i) - if _byte(s, i) ~= 58 then error("expected :") end - i = skipWs(s, i + 1) - local v - v, i = jsonDecodeValue(s, i) - out[k] = v - i = skipWs(s, i) - local c = _byte(s, i) - if c == 44 then i = skipWs(s, i + 1) - elseif c == 125 then return out, i + 1 - else error("expected , or }") end - end -end - -jsonDecodeValue = function(s, i) - i = skipWs(s, i) - local c = _byte(s, i) - if c == 34 then return decodeStr(s, i) end - if c == 123 then return decodeObj(s, i) end - if c == 91 then return decodeArr(s, i) end - if c == 116 and _sub(s, i, i + 3) == "true" then return true, i + 4 end - if c == 102 and _sub(s, i, i + 4) == "false" then return false, i + 5 end - if c == 110 and _sub(s, i, i + 3) == "null" then return nil, i + 4 end - if c == 45 or (c and c >= 48 and c <= 57) then return decodeNum(s, i) end - error("unexpected char at " .. tostring(i) .. ": " .. tostring(c)) -end - -local function jsonDecode(s) - local v = jsonDecodeValue(s, 1) - return v -end - -local json = { encode = jsonEncode, decode = jsonDecode } - --------------------------------------------------------------------------------- --- Config --------------------------------------------------------------------------------- - -local HOST = "127.0.0.1" -local PORT = 8200 -local MAX_OUT_BUF = 1024 * 1024 -- 1 MB before we drop the client -local MAX_RESULT_LEN = 64 * 1024 -- truncate huge result strings -local MAX_TABLE_KEYS = 200 - --------------------------------------------------------------------------------- --- State --------------------------------------------------------------------------------- - -local server -- listening socket -local clients = {} -- numeric list of sockets, used as set for socket.select -local stateBy = {} -- sock -> { sock, inBuf, outBuf, streamLogs, peer } - --------------------------------------------------------------------------------- --- Helpers --------------------------------------------------------------------------------- - -local function writeFrame(c, frame) - local ok, encoded = pcall(json.encode, frame) - if not ok then return end - c.outBuf = c.outBuf .. encoded .. "\n" - if #c.outBuf > MAX_OUT_BUF then - c.overflowed = true - end -end - -local function prettyValue(v, depth, lenAcc) - depth = depth or 0 - if depth > 5 then return "<...>" end - local t = type(v) - if t == "string" then - if #v > MAX_RESULT_LEN then - return v:sub(1, MAX_RESULT_LEN) .. "..." - end - return v - end - if t == "nil" then return "nil" end - if t == "number" or t == "boolean" then return tostring(v) end - if t == "function" or t == "userdata" or t == "thread" then return "<" .. t .. ">" end - if t == "table" then - local parts = { "{" } - local pad = string.rep(" ", depth + 1) - local count = 0 - for k, val in pairs(v) do - count = count + 1 - if count > MAX_TABLE_KEYS then - parts[#parts + 1] = pad .. "...(" .. count .. "+ entries)" - break - end - local keyStr = (type(k) == "string" and ("[" .. string.format("%q", k) .. "]")) or ("[" .. tostring(k) .. "]") - parts[#parts + 1] = pad .. keyStr .. " = " .. prettyValue(val, depth + 1) .. "," - end - parts[#parts + 1] = string.rep(" ", depth) .. "}" - return table.concat(parts, "\n") - end - return tostring(v) -end - -local execEnv -- shared sandbox for repeated EXEC frames so locals persist - -local function getExecEnv() - if not execEnv then - execEnv = setmetatable({}, { __index = getfenv(1) }) - end - return execEnv -end - -local function runLua(code, asExpression) - local fn, err - if asExpression then - fn = loadstring("return " .. code, "claude_bridge") - end - if not fn then - fn, err = loadstring(code, "claude_bridge") - end - if not fn then return false, err end - setfenv(fn, getExecEnv()) - local results = { pcall(fn) } - local ok = table.remove(results, 1) - if not ok then return false, results[1] end - if #results == 0 then return true, nil end - if #results == 1 then return true, results[1] end - return true, results -end - --------------------------------------------------------------------------------- --- Frame dispatch --------------------------------------------------------------------------------- - -local function handleFrame(c, frame) - local id = frame.id - local kind = frame.kind - - if kind == "ping" then - writeFrame(c, { id = id, kind = "result", ok = true, value = "pong" }) - - elseif kind == "exec" then - -- Run as statement (no auto-return). Captures the explicit return value. - local ok, ret = runLua(frame.code or "", false) - if ok then - writeFrame(c, { id = id, kind = "result", ok = true, value = prettyValue(ret) }) - else - writeFrame(c, { id = id, kind = "result", ok = false, error = tostring(ret) }) - end - - elseif kind == "eval" then - -- Try as expression first (so 'eval Spring.GetGameFrame()' works), then statement. - local ok, ret = runLua(frame.code or "", true) - if ok then - writeFrame(c, { id = id, kind = "result", ok = true, value = prettyValue(ret) }) - else - writeFrame(c, { id = id, kind = "result", ok = false, error = tostring(ret) }) - end - - elseif kind == "cmd" then - local cmd = frame.code or frame.cmd or "" - Spring.SendCommands(cmd) - writeFrame(c, { id = id, kind = "result", ok = true, value = "" }) - - elseif kind == "log_subscribe" then - c.streamLogs = true - writeFrame(c, { id = id, kind = "result", ok = true, value = "log streaming on" }) - - elseif kind == "log_unsubscribe" then - c.streamLogs = false - writeFrame(c, { id = id, kind = "result", ok = true, value = "log streaming off" }) - - elseif kind == "screenshot" then - Spring.SendCommands("screenshot " .. (frame.format or "png")) - writeFrame(c, { id = id, kind = "result", ok = true, value = "screenshot triggered" }) - - elseif kind == "info" then - writeFrame(c, { id = id, kind = "result", ok = true, value = { - gameFrame = Spring.GetGameFrame(), - gameSeconds = Spring.GetGameSeconds(), - myTeamID = Spring.GetMyTeamID(), - myPlayerID = Spring.GetMyPlayerID(), - isReplay = Spring.IsReplay(), - } }) - - else - writeFrame(c, { id = id, kind = "result", ok = false, error = "unknown kind: " .. tostring(kind) }) - end -end - -local function processIncoming(c) - while true do - local nl = c.inBuf:find("\n", 1, true) - if not nl then break end - local line = c.inBuf:sub(1, nl - 1) - c.inBuf = c.inBuf:sub(nl + 1) - if line ~= "" and line ~= "\r" then - if line:sub(-1) == "\r" then line = line:sub(1, -2) end - local ok, frame = pcall(json.decode, line) - if ok and type(frame) == "table" then - local handlerOk, handlerErr = pcall(handleFrame, c, frame) - if not handlerOk then - writeFrame(c, { id = frame.id, kind = "result", ok = false, error = "handler crashed: " .. tostring(handlerErr) }) - end - else - writeFrame(c, { kind = "error", error = "bad json: " .. line:sub(1, 120) }) - end - end - end -end - --------------------------------------------------------------------------------- --- Client lifecycle --------------------------------------------------------------------------------- - -local function closeClient(sock, why) - local c = stateBy[sock] - if not c then return end - pcall(function() sock:close() end) - stateBy[sock] = nil - for i = #clients, 1, -1 do - if clients[i] == sock then table.remove(clients, i) end - end - Spring.Echo(string.format("[ClaudeBridge] client %s disconnected (%s)", c.peer or "?", why or "closed")) -end - -local function acceptOne() - if not server then return end - local newSock = server:accept() - if not newSock then return end - newSock:settimeout(0) - local ip, port = newSock:getpeername() - local peer = string.format("%s:%s", tostring(ip), tostring(port)) - local c = { sock = newSock, inBuf = "", outBuf = "", streamLogs = false, peer = peer } - stateBy[newSock] = c - clients[#clients + 1] = newSock - Spring.Echo(string.format("[ClaudeBridge] client %s connected", peer)) - writeFrame(c, { kind = "hello", value = "claude-bridge ready", gameFrame = Spring.GetGameFrame() }) -end - --------------------------------------------------------------------------------- --- Widget callins --------------------------------------------------------------------------------- - -function widget:Initialize() - server = socket.bind(HOST, PORT) - if not server then - Spring.Echo(string.format("[ClaudeBridge] cannot bind %s:%d - check TCPAllowListen in springsettings.cfg", HOST, PORT)) - widgetHandler:RemoveWidget() - return - end - server:settimeout(0) - Spring.Echo(string.format("[ClaudeBridge] listening on %s:%d", HOST, PORT)) -end - -function widget:Shutdown() - if server then pcall(function() server:close() end); server = nil end - for sock, _ in pairs(stateBy) do pcall(function() sock:close() end) end - stateBy = {} - clients = {} -end - -function widget:AddConsoleLine(line, priority) - if not line or line == "" then return end - -- Skip our own bridge chatter to avoid feedback loops in the streaming output. - if line:find("[ClaudeBridge]", 1, true) then return end - for _, sock in ipairs(clients) do - local c = stateBy[sock] - if c and c.streamLogs and not c.overflowed then - writeFrame(c, { kind = "log", msg = line, priority = priority }) - end - end -end - -function widget:Update() - if not server then return end - acceptOne() - - if #clients == 0 then return end - - local readable, writable, err = socket.select(clients, clients, 0) - if err and err ~= "timeout" then - Spring.Echo("[ClaudeBridge] select error: " .. tostring(err)) - return - end - - for _, sock in ipairs(readable or {}) do - local data, status, partial = sock:receive("*a") - if status == "closed" then - closeClient(sock, "remote closed") - else - local chunk = data or partial - if chunk and chunk ~= "" then - local c = stateBy[sock] - if c then - c.inBuf = c.inBuf .. chunk - processIncoming(c) - end - end - end - end - - for _, sock in ipairs(writable or {}) do - local c = stateBy[sock] - if c then - if c.overflowed then - closeClient(sock, "outbuf overflow") - elseif c.outBuf ~= "" then - local n, sendErr, partial2 = sock:send(c.outBuf) - if not n and sendErr == "closed" then - closeClient(sock, "send closed") - elseif n then - c.outBuf = c.outBuf:sub(n + 1) - elseif partial2 then - c.outBuf = c.outBuf:sub(partial2 + 1) - end - end - end - end -end diff --git a/LuaUI/cawidgets.lua b/LuaUI/cawidgets.lua index 6c83f0e5e8..18768b05ca 100644 --- a/LuaUI/cawidgets.lua +++ b/LuaUI/cawidgets.lua @@ -1838,7 +1838,6 @@ function widgetHandler:DrawWorldRefraction() end - function widgetHandler:DrawUnitsPostDeferred() tracy.ZoneBeginN("W:DrawUnitsPostDeferred") for _, w in r_ipairs(self.DrawUnitsPostDeferredList) do diff --git a/scripts/energywind.lua b/scripts/energywind.lua index b06971c6f1..cd9bf793c1 100644 --- a/scripts/energywind.lua +++ b/scripts/energywind.lua @@ -8,12 +8,12 @@ local smokePiece = {base} local hpi = math.pi*0.5 local UPDATE_PERIOD = 1000 +local BUILD_PERIOD = 500 local turnSpeed = math.rad(20) local waterFanSpin = math.rad(30) local SIG_ANIM = 1 -local SIG_WIND = 2 local isWind, baseWind, rangeWind local rand = math.random @@ -40,16 +40,21 @@ local function BobTidal() end local oldWindStrength, oldWindHeading -local function SpinWind() - SetSignalMask(SIG_WIND) +function SpinWind() while true do - if GG.WindStrength and ((oldWindStrength ~= GG.WindStrength) or (oldWindHeading ~= GG.WindHeading)) then - oldWindStrength, oldWindHeading = GG.WindStrength, GG.WindHeading - local st = baseWind + (GG.WindStrength or 0)*rangeWind - Spin(fan, z_axis, -st*(0.94 + 0.08*rand())) - Turn(cradle, y_axis, GG.WindHeading - baseDirection + math.pi, turnSpeed) + if select(5, Spring.GetUnitHealth(unitID)) < 1 then + oldWindStrength = nil + StopSpin(fan, z_axis) + Sleep(BUILD_PERIOD) + else + if GG.WindStrength and ((oldWindStrength ~= GG.WindStrength) or (oldWindHeading ~= GG.WindHeading)) then + oldWindStrength, oldWindHeading = GG.WindStrength, GG.WindHeading + local st = baseWind + (GG.WindStrength or 0)*rangeWind + Spin(fan, z_axis, -st*(0.94 + 0.08*rand())) + Turn(cradle, y_axis, GG.WindHeading - baseDirection + math.pi, turnSpeed) + end + Sleep(UPDATE_PERIOD + 200*rand()) end - Sleep(UPDATE_PERIOD + 200*rand()) if GG.Wind_SpinDisabled then StopSpin(fan, z_axis) @@ -58,39 +63,10 @@ local function SpinWind() end end --- Started/stopped via script.Activate/script.Deactivate (since the unit def has --- activateWhenBuilt=true). Avoids a per-second Spring.GetUnitHealth poll on every --- windmill just to detect build completion. EMP/disarm also stops the spin via --- engine-driven Deactivate, matching the behaviour of other ZK animated buildings. -function script.Activate() - if not isWind then - return - end - Signal(SIG_WIND) - oldWindStrength, oldWindHeading = nil, nil - StartThread(SpinWind) -end - -function script.Deactivate() - if not isWind then - return - end - Signal(SIG_WIND) - StopSpin(fan, z_axis) -end - function InitializeWind() isWind, baseWind, rangeWind = GG.SetupWindmill(unitID) if isWind then - -- Spin starts on script.Activate (engine fires it when build completes, - -- because the unit def sets activateWhenBuilt=true). - -- For the /windanim debug toggle re-init path, kick the thread now if the - -- unit is already built and active. - if Spring.GetUnitIsActive(unitID) then - Signal(SIG_WIND) - oldWindStrength, oldWindHeading = nil, nil - StartThread(SpinWind) - end + StartThread(SpinWind) else StartThread(BobTidal) Hide(base) From d83d20f357c057bac7c5344dac9e8f3aa3812e82 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 18:01:24 +0200 Subject: [PATCH 46/59] overdrive_cables: extract GetCurWindFrac, tie BUBBLE_CAP_REF to MAX_CAPACITY_REF Dedupes the windFrac (0..1 clamped read of WindStrength rules-param) call across ComputeMaxPotentials, ConsumersOrWindChanged, and SendAll. Centralizes the rules-param key string in one place. BUBBLE_CAP_REF now references MAX_CAPACITY_REF directly so the two cannot drift; the comment "(one singu)" lives on the MAX_CAPACITY_REF declaration and that is enough. --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index a4b5d657d6..ea59792d92 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -201,6 +201,12 @@ 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. @@ -1385,8 +1391,7 @@ local function ComputeMaxPotentials(flowMode) -- 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 = Spring.GetGameRulesParam("WindStrength") or 0 - if windFrac < 0 then windFrac = 0 elseif windFrac > 1 then windFrac = 1 end + local windFrac = GetCurWindFrac() -- subPcur from cached aggregates: 0 per-pylon reads. subPcur = {} @@ -1529,9 +1534,7 @@ local function ConsumersOrWindChanged() return true end end - local newWindFrac = Spring.GetGameRulesParam("WindStrength") or 0 - if newWindFrac < 0 then newWindFrac = 0 elseif newWindFrac > 1 then newWindFrac = 1 end - if math.abs(newWindFrac - lastWindFrac) > 0.05 then return true end + if math.abs(GetCurWindFrac() - lastWindFrac) > 0.05 then return true end return false end @@ -1650,11 +1653,7 @@ local function SendAll() for uid, did in pairs(consumerNodeIndex) do lastConsumerDcur[uid] = GetNodeDcurrent(uid, did) end - do - local newWindFrac = Spring.GetGameRulesParam("WindStrength") or 0 - if newWindFrac < 0 then newWindFrac = 0 elseif newWindFrac > 1 then newWindFrac = 1 end - lastWindFrac = newWindFrac - end + lastWindFrac = GetCurWindFrac() if perf then local tEnd = Spring.GetTimer() @@ -1980,7 +1979,7 @@ 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 = 225.0 -- mirror of MAX_CAPACITY_REF (one singu) +local BUBBLE_CAP_REF = MAX_CAPACITY_REF local function widthOfCapacity(cap) local t = (cap or 0) / BUBBLE_CAP_REF From ec296f18956e8a76ac99b6d31f4f532b3af07756 Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 18:05:51 +0200 Subject: [PATCH 47/59] remove unused cable.png Leftover texture from an earlier experiment; no code references it (cables are drawn entirely via shaders, no texture sampling). --- LuaRules/Images/overdrive/cable.png | Bin 362 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 LuaRules/Images/overdrive/cable.png diff --git a/LuaRules/Images/overdrive/cable.png b/LuaRules/Images/overdrive/cable.png deleted file mode 100644 index 273df3275a1b518058da0fad2efd7a2731f8e882..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 362 zcmV-w0hRuVP);$JcRKACb}>&vH>(zOG;@0L#yb5bo&BZf+lW*<|aw z!Mduk%MFTR^-|;omiZF%$NTR4YExzcWlS4_!>AiLy|~0Injwrr9EDR%Mh1avW8&J| z93AY}9DmkWloh_pYL{0Vyrpxbaf&2P@Nje0&HYH8pw zO+ Date: Thu, 30 Apr 2026 18:42:15 +0200 Subject: [PATCH 48/59] overdrive_cables: sample $info:los instead of $info for LOS gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOS leak: when the user disabled their LOS overlay (info display = height, or any non-LOS mode), the FS sampled the overlay texture instead of LOS, the grayscale heuristic remapped to losState≈1, the enemy ghost gate never fired, and out-of-LOS enemy cables rendered with full live flow. $info:los is the actual game-logic LOS texture (single-channel red), independent of the user's overlay toggle — what infoLOS.lua's bind chain already uses for the engine's own LOS rendering. --- LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl | 7 ++++--- LuaRules/Gadgets/gfx_overdrive_cables.lua | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index 8fe5e16d7e..d07a234c8c 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -290,10 +290,11 @@ void main() { float surfN = hash(worldPos.xz * 0.5) * 0.04; baseColor += vec3(surfN); - // LOS state (needed first for animation gating) + // 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 losTexSample = dot(vec3(0.33), texture(infoTex, losUV).rgb); - float losState = clamp(losTexSample * 4.0 - 1.0, 0.0, 1.0); + float losState = texture(infoTex, losUV).r; float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); // Enemy cables out of LOS: render as a flat dim ghost reflecting the last diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index ea59792d92..a03a3a12bf 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -2380,7 +2380,11 @@ function gadget:DrawWorldPreUnit() cableShader:SetUniform("bakeTime", bubbleBakeTime) cableShader:SetUniform("enableFlow", cableFlowMode and 1.0 or 0.0) - gl.Texture(0, "$info") + -- $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) From ad4aff2e65307f513f5cfc58b9adeb5a77777c6e Mon Sep 17 00:00:00 2001 From: Licho Date: Thu, 30 Apr 2026 23:50:39 +0200 Subject: [PATCH 49/59] disable ghosting - hide ghosts --- .../Shaders/gfx_overdrive_cables.frag.glsl | 38 +++++-------------- LuaRules/Gadgets/gfx_overdrive_cables.lua | 7 ++-- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index d07a234c8c..88cb18b6ef 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -55,12 +55,10 @@ const float DIM_FACTOR_MIN = 0.3; // bark brightness at full darkne const float FULLLOS_LO = 0.7; const float FULLLOS_HI = 1.0; -// Enemy ghost (non-own ally outside LOS): flat dim look, no animation. -const vec3 GHOST_BASE_LO = vec3(0.30); // capT = 0 -const vec3 GHOST_BASE_HI = vec3(0.55); // capT = 1 -const float GHOST_BRANCH_DAMP = 0.65; -const float GHOST_LOS_THRESH = 0.45; -const float GHOST_ALPHA_MAX = 0.55; +// 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. @@ -297,30 +295,12 @@ void main() { float losState = texture(infoTex, losUV).r; float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); - // Enemy cables out of LOS: render as a flat dim ghost reflecting the last - // known state (the synced gadget broadcasts every ally team's grid to all - // clients, so we already hold the last-received topology even after LOS - // is lost). Skip live shading + bubble animation — ghost is static so it - // reads as "memory" rather than current activity. Own cables stay live - // (they're always visible to the local viewer). - // - // We early-out here so the bubble layer pass and bark lighting below - // don't run for ghosts; they'd be wasted work. + // Enemy cables outside LOS: hide entirely (proper ghosting will be added + // later as a separate pass with last-seen geometry; the live pass should + // only show what's actually visible right now). Own ally always renders + // — fades via dimFactor for fog, but stays on screen. float isOwnAlly = gridData.w; - if (isOwnAlly < 0.5 && losState < GHOST_LOS_THRESH) { - // Neutral grayish ghost — a "remembered" cable, not a live circuit. - // Capacity barely tints brightness so thicker grid lines read slightly - // brighter without picking up a hue. Branches are a touch dimmer. - float capT = clamp(capacity / 100.0, 0.0, 1.0); - vec3 ghostBase = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT); - if (isBranch > 0.5) ghostBase *= GHOST_BRANCH_DAMP; - // Edge falloff so the ribbon edges fade rather than hard-cut, giving - // the ghost a softer "remembered impression" look. Drives alpha too, - // so the silhouette dissolves smoothly instead of hard-clipping at t=0.9. - float edgeFade = 1.0 - smoothstep(0.55, 0.90, t); - fragColor = vec4(ghostBase * edgeFade, GHOST_ALPHA_MAX * edgeFade); - return; - } + if (isOwnAlly < 0.5 && losState < ENEMY_LOS_CUT) discard; // Apply lighting vec3 color = baseColor * diffuse + SPEC_TINT * spec; diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index a03a3a12bf..6afea56435 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -2389,9 +2389,10 @@ function gadget:DrawWorldPreUnit() gl.Culling(false) gl.DepthTest(GL.LEQUAL) gl.DepthMask(true) - -- Standard alpha blending. Live cables write alpha=1.0 (visually identical - -- to no-blend), ghosts write alpha<1.0 to compose against the world. - gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) + -- Fully opaque output — every path writes alpha=1.0 (enemy ghost branch + -- is gone; out-of-LOS enemy fragments are discarded). Disable blending + -- so depth-tested cables compose cleanly against the world. + gl.Blending(false) -- GL_LINES: every 2 verts form one cable; the geometry shader expands -- them into a triangle_strip ribbon. From 166483bb9feb9263bee7c13576864b94f9e03314 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 01:11:19 +0200 Subject: [PATCH 50/59] experimental los1 --- .../Shaders/gfx_overdrive_cables.frag.glsl | 64 +++- .../Shaders/gfx_overdrive_cables.geom.glsl | 37 +- .../Shaders/gfx_overdrive_cables.vert.glsl | 6 +- LuaRules/Gadgets/gfx_overdrive_cables.lua | 324 +++++++++++++++++- LuaUI/Widgets/gfx_overdrive_cables_menu.lua | 29 +- 5 files changed, 435 insertions(+), 25 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index 88cb18b6ef..88a8281e5d 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -1,12 +1,18 @@ -#version 420 +#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) +// Same SSBO as the GS — slot 0 is the FS-write probe target (debug only). +layout (std430, binding = 6) coherent buffer cableCoverageBuffer { + uvec4 cableCoverage[]; +}; + in DataGS { vec3 worldPos; float capacity; @@ -16,6 +22,8 @@ in DataGS { vec2 timeData; vec4 gridData; float spawnAlongMain; + flat int gsSlot; + flat int gsNumSeg; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -209,6 +217,12 @@ vec3 gridEfficiencyColor(float eff) { return hueToRgb(h / 255.0); } +// Ghost shading constants — flat memory render for unscouted enemy fragments +// that fall inside the seen-segment range. +const vec3 GHOST_BASE_LO = vec3(0.30); // capT = 0 +const vec3 GHOST_BASE_HI = vec3(0.55); // capT = 1 +const float GHOST_BRANCH_DAMP = 0.65; + void main() { float v = cableUV.y; float t = abs(v); @@ -295,12 +309,50 @@ void main() { float losState = texture(infoTex, losUV).r; float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); - // Enemy cables outside LOS: hide entirely (proper ghosting will be added - // later as a separate pass with last-seen geometry; the live pass should - // only show what's actually visible right now). Own ally always renders - // — fades via dimFactor for fog, but stays on screen. + // Per-fragment segment index. Without a baked len-per-segment we use a + // single bit (bit 0) for the whole cable in slice 1; per-segment + // resolution comes when we wire len-per-segment via a uniform/varying. + uint segBit = 1u; + + // FS-side coverage update: any fragment that's actually rasterised AND + // currently in LOS marks its cable as seen. + if (losState >= ENEMY_LOS_CUT && gsSlot >= 0) { + atomicOr(cableCoverage[gsSlot].x, segBit); + } + + // 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; - if (isOwnAlly < 0.5 && losState < ENEMY_LOS_CUT) discard; + bool isGhostEdge = isOwnAlly < -0.5; + + // Re-scout clear: when a ghost fragment ends up in LOS the player has + // confirmed the area is empty (the live edge is gone). atomicAnd off + // this segment's bit and discard so it stops rendering. Per-fragment + // granularity, so partial scouts thin the ghost progressively. Only + // the ghost VBO does this — live edges set bits via atomicOr below + // and never want to clear them. + if (isGhostEdge && losState >= ENEMY_LOS_CUT && gsSlot >= 0) { + atomicAnd(cableCoverage[gsSlot].x, ~segBit); + discard; + } + + bool enemyOutOfLOS = (isOwnAlly < 0.5 && isOwnAlly > -0.5 && losState < ENEMY_LOS_CUT); + if (isGhostEdge || enemyOutOfLOS) { + uint cov = (gsSlot >= 0) ? cableCoverage[gsSlot].x : 0u; + if ((cov & segBit) == 0u) discard; + float capT2 = clamp(capacity / 100.0, 0.0, 1.0); + vec3 ghost = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT2); + if (isBranch > 0.5) ghost *= GHOST_BRANCH_DAMP; + float edgeFade = 1.0 - smoothstep(0.55, 0.90, t); + fragColor = vec4(ghost * edgeFade, 1.0); + return; + } // Apply lighting vec3 color = baseColor * diffuse + SPEC_TINT * spec; diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl index 6323f1e114..a768d1b134 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -1,7 +1,8 @@ -#version 330 +#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 @@ -18,12 +19,29 @@ layout (triangle_strip, max_vertices = 50) out; uniform sampler2D heightmapTex; +// Per-edge "have I been seen" bitmask. Bit `i` of `.x` is set when segment +// `i` of this cable is currently in LOS. Persistent across frames; never +// cleared in slice 1 (slice 3 will clear bits during the ghost pass when +// the player's LOS confirms the area is empty). +// +// 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. +// gsLenPerSeg packed at the head of `cableUV` to avoid an extra varying: +// .x = along-elmos (existing), .y = cross [-1,1] (existing). +// Instead, pack lenPerSeg + slot into spawnAlongMain.zw — but that's vec2. +// Simpler: keep it as one float and check max-comp budget. out DataGS { vec3 worldPos; float capacity; @@ -32,7 +50,9 @@ out DataGS { vec2 cableUV; vec2 timeData; vec4 gridData; - float spawnAlongMain; // twig-only: global cableUV.x of the twig's root; 0 for main ribbon. Lets the FS compute twig-local along for sub-wave animation. + float spawnAlongMain; + flat int gsSlot; + flat int gsNumSeg; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -100,6 +120,9 @@ const float SIDE_CLEAR = 0.8; float gOutBranch = 0.0; float gOutSpawnAlong = 0.0; // set by emitTwig per-twig; main ribbon leaves at 0. +int gOutSlot = -1; // SSBO slot for this cable; carried into every emitVtx. +int gOutNumSeg = 0; // segment count for this cable. +float gOutLenPerSeg = 0.0; // along-elmos per segment; FS divides cableUV.x by this to get segIdx. void emitVtx(vec3 wp, vec3 tangent3D, vec2 cuv, float w, vec4 grid, vec2 td, float cap) { @@ -111,6 +134,8 @@ void emitVtx(vec3 wp, vec3 tangent3D, vec2 cuv, timeData = td; gridData = grid; spawnAlongMain = gOutSpawnAlong; + gsSlot = gOutSlot; + gsNumSeg = gOutNumSeg; // (vsTangent varying disabled — exceeded GS output budget on this hardware) gl_Position = cameraViewProj * vec4(wp, 1.0); EmitVertex(); @@ -451,6 +476,14 @@ void main() { // Per-segment probing was the source of zigzag — see cableArcDh comment. float arcDh = cableArcDh(a, d, perpAB, lenAB); + gOutSlot = dataIn[0].vsSlot; + gOutNumSeg = numSeg; + // Approximate the cable's total along-distance with len3D (the GS later + // recomputes it precisely as a sum of segment distances; for FS bit + // indexing the chord-based len3D is close enough — both ends of the + // segment fall in the same bit at LOS-tile resolution). + gOutLenPerSeg = (numSeg > 0) ? (len3D / float(numSeg)) : 1.0; + if (gl_InvocationID == 0) { emitMainRibbon(a, d, perpAB, halfW, widthVal, effAmp, seed, gridD, timeD, cap, numSeg, arcDh); } else { diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl index 43c7a24085..422e45f989 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.vert.glsl @@ -1,6 +1,7 @@ -#version 420 +#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 @@ -10,6 +11,7 @@ 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; @@ -19,11 +21,13 @@ 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 index 6afea56435..77f012a4ce 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -356,6 +356,71 @@ local cableDetail = readDetailFromConfig() local cableEnabled = cableDetail ~= DETAIL_OFF local cablePerf = false local cableFlowMode = cableDetail == DETAIL_FULL +-- Ghost rendering: when on, the GS samples LOS at each cable segment center +-- and atomicOr's the per-edge coverage SSBO. A separate ghost pass (slice 3) +-- consumes those bits to render last-seen segments of dead/orphaned enemy +-- cables. Slice 1 only fills the SSBO so we can verify the bit accumulation; +-- the live render is unchanged. +local cableGhosts = (Spring.GetConfigInt("OverdriveCableGhosts", 1) or 1) ~= 0 + +-- --------------------------------------------------------------------------- +-- Per-edge coverage SSBO (slice 1: bits set by GS, not yet consumed) +-- --------------------------------------------------------------------------- +-- Each cable owns one slot in `coverageSSBO`. The GS atomicOr's bit `i` +-- whenever segment `i` is currently in LOS during the live pass. The slot +-- index is allocated CPU-side per edgeKey and freed when the edge has no +-- live presence and no remaining ghost coverage. +-- +-- 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 keyBySlot = {} -- slot -> edgeKey +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 + keyBySlot[s] = edgeKey + -- 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 + keyBySlot[s] = 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. Args: (data, attribIdx, + -- elemOffset). attribIdx=0 is our only attribute; elemOffset places the + -- single-element write at slot s. + if coverageSSBO then + coverageSSBO:Upload({0, 0, 0, 0}, 0, s) + end +end -- Per-tick perf stats. Filled by SyncWithGrid / ComputeMaxPotentials / -- SendAll only when cablePerf is on; RunSyncTick reads them and emits one @@ -1778,8 +1843,40 @@ 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 @@ -1791,17 +1888,52 @@ local function CableTreeCmd(cmd, line, words, playerID) 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) + 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 edges=%d", - DETAIL_NAMES[cableDetail], tostring(cablePerf), nEdges)) + "[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 | perf | status") + Spring.Echo("[CableTree] usage: /cabletree detail off|noflow|full | ghosts | perf | status") end return true end @@ -2011,6 +2143,21 @@ 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). +local ghostEdges = {} +local ghostVAO +local numGhostVerts = 0 +local ghostNeedsRebuild = false + -- 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, @@ -2115,20 +2262,80 @@ local function GenerateOrganicTree() 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 (9 floats: pos2 + data3 + grid4) + -- 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+10] = e.cx; verts[k+11] = e.cz - verts[k+12] = cap; verts[k+13] = appearTime; verts[k+14] = witherTime - verts[k+15] = eff; verts[k+16] = flow; verts[k+17] = phase; verts[k+18] = isOwn - k = k + 18 + 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() + if vertCount == 0 then + ghostVAO = nil + numGhostVerts = 0 + return + end + local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) + if not vbo then return end + vbo:Define(vertCount, { + { id = 0, name = "vertPos", size = 2 }, + { id = 1, name = "vertData", size = 3 }, + { id = 2, name = "vertGrid", size = 4 }, + { id = 3, name = "vertSlot", size = 1 }, + }) + vbo:Upload(verts) + ghostVAO = gl.GetVAO() + if ghostVAO then ghostVAO:AttachVertexBuffer(vbo) end + numGhostVerts = vertCount +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). @@ -2197,14 +2404,43 @@ function OnCableTreeFull(data) incoming[data.keys[i]] = i end - -- Mark missing edges as withering (or leave them withering if already so). + -- 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. for k, e in pairs(existing) do if not incoming[k] and not e.witherFrame then - e.witherFrame = frame + if not e.isOwnAlly then + if 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 + 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), drop the ghost — live takes over. The slot stays bound to the + -- live edge via slotByKey; bits accumulated under the ghost get inherited. + for k in pairs(incoming) do + if ghostEdges[k] then + ghostEdges[k] = nil + ghostNeedsRebuild = 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 @@ -2230,7 +2466,16 @@ function OnCableTreeFull(data) -- positions are stable for unchanged edges; assign anyway in case parent moved e.px, e.pz = data.pxs[i], data.pzs[i] e.cx, e.cz = data.cxs[i], data.czs[i] + -- 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 existing[k] = { px = data.pxs[i], pz = data.pzs[i], cx = data.cxs[i], cz = data.czs[i], @@ -2241,6 +2486,7 @@ function OnCableTreeFull(data) appearFrame = frame, 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, @@ -2296,13 +2542,14 @@ local function RebuildVBO() cableVAO = nil local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) if not vbo then return end - -- Per-vertex layout (9 floats): vertPos(2) + vertData(3) + vertGrid(4). - -- Two vertices per cable form one GL_LINES primitive; the geometry shader - -- expands each line into a wiggly ribbon at draw time. + -- 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) @@ -2362,6 +2609,12 @@ function gadget:GameFrame(n) 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 end function gadget:DrawWorldPreUnit() @@ -2380,6 +2633,16 @@ function gadget:DrawWorldPreUnit() cableShader:SetUniform("bakeTime", bubbleBakeTime) cableShader:SetUniform("enableFlow", cableFlowMode and 1.0 or 0.0) + -- Bind the per-edge coverage SSBO at binding=6 for GS atomicOr writes + -- and (slice 3) ghost-pass reads. Always bound when shader is active so + -- we don't have to manage a separate ghost program. + 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 @@ -2398,6 +2661,20 @@ function gadget:DrawWorldPreUnit() -- them into a triangle_strip ribbon. cableVAO:DrawArrays(GL.LINES, numCableVerts) + -- Ghost pass: orphaned enemy edges. Same shader, same SSBO; the FS uses + -- gridData.w=-1.0 sentinel to force the ghost branch regardless of LOS. + if ghostVAO and numGhostVerts > 0 then + ghostVAO:DrawArrays(GL.LINES, numGhostVerts) + end + + -- Make GS atomicOr writes visible to subsequent SSBO consumers (slice 3 + -- ghost pass) and to CPU-side Download. 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) @@ -2442,6 +2719,27 @@ function gadget:Initialize() 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 + -- Topology side: register chat command + scan existing pylons. InitTopology() end diff --git a/LuaUI/Widgets/gfx_overdrive_cables_menu.lua b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua index 4afc68ec9a..7bd441c146 100644 --- a/LuaUI/Widgets/gfx_overdrive_cables_menu.lua +++ b/LuaUI/Widgets/gfx_overdrive_cables_menu.lua @@ -27,6 +27,7 @@ function widget:GetInfo() 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 } @@ -35,8 +36,12 @@ local function readCurrentDetailKey() 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' } +options_order = { 'cabletree_detail', 'cabletree_ghosts' } options = { cabletree_detail = { @@ -54,6 +59,16 @@ options = { 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() @@ -62,9 +77,11 @@ function widget:Initialize() -- 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 SetDetailLevel returns early if level is unchanged). + -- 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 @@ -72,11 +89,17 @@ end -- 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 } + 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 From dc8b9399e38257ed71a85b6bd50b3dc474ab8468 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 01:16:23 +0200 Subject: [PATCH 51/59] experiment 2 GS --- .../Shaders/gfx_overdrive_cables.frag.glsl | 36 +++++++++---------- .../Shaders/gfx_overdrive_cables.geom.glsl | 30 +++++++++++++--- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index 88a8281e5d..586204fd7f 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -309,16 +309,17 @@ void main() { float losState = texture(infoTex, losUV).r; float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); - // Per-fragment segment index. Without a baked len-per-segment we use a - // single bit (bit 0) for the whole cable in slice 1; per-segment - // resolution comes when we wire len-per-segment via a uniform/varying. - uint segBit = 1u; - - // FS-side coverage update: any fragment that's actually rasterised AND - // currently in LOS marks its cable as seen. - if (losState >= ENEMY_LOS_CUT && gsSlot >= 0) { - atomicOr(cableCoverage[gsSlot].x, segBit); - } + // Coverage bits are written by the GS once per cable per frame (cheap) + // rather than here in the FS (per-fragment = millions of atomics on + // thousand-cable matches). The FS only READS the coverage to decide + // ghost rendering. + // + // segBit = "any segment seen". We don't yet pass len-per-segment to + // the FS so per-segment fragment-side gating isn't possible — the + // whole cable shows as ghost when any of its bits are set, and hides + // when all bits clear. GS sets/clears per-segment so re-scout still + // dissolves the ghost correctly as the player walks the area. + uint segBit = 0xFFFFFFu; // Three render classes for the FS: // isOwnAlly = 1.0 → own ally, always live (existing path below). @@ -331,16 +332,11 @@ void main() { float isOwnAlly = gridData.w; bool isGhostEdge = isOwnAlly < -0.5; - // Re-scout clear: when a ghost fragment ends up in LOS the player has - // confirmed the area is empty (the live edge is gone). atomicAnd off - // this segment's bit and discard so it stops rendering. Per-fragment - // granularity, so partial scouts thin the ghost progressively. Only - // the ghost VBO does this — live edges set bits via atomicOr below - // and never want to clear them. - if (isGhostEdge && losState >= ENEMY_LOS_CUT && gsSlot >= 0) { - atomicAnd(cableCoverage[gsSlot].x, ~segBit); - discard; - } + // 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 (isGhostEdge || enemyOutOfLOS) { diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl index a768d1b134..fc6b0fc85b 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -18,6 +18,7 @@ layout (lines, invocations = 5) in; layout (triangle_strip, max_vertices = 50) out; uniform sampler2D heightmapTex; +uniform sampler2D infoTex; // $info:los; same texture FS samples // Per-edge "have I been seen" bitmask. Bit `i` of `.x` is set when segment // `i` of this cable is currently in LOS. Persistent across frames; never @@ -478,13 +479,32 @@ void main() { gOutSlot = dataIn[0].vsSlot; gOutNumSeg = numSeg; - // Approximate the cable's total along-distance with len3D (the GS later - // recomputes it precisely as a sum of segment distances; for FS bit - // indexing the chord-based len3D is close enough — both ends of the - // segment fall in the same bit at LOS-tile resolution). - gOutLenPerSeg = (numSeg > 0) ? (len3D / float(numSeg)) : 1.0; 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 (confirmed empty). Sampling along the actual + // wiggly path keeps reveal accurate even with arc bias on slopes. + int slot = dataIn[0].vsSlot; + bool isGhost = gridD.w < -0.5; + if (slot >= 0) { + uint setMask = 0u; + uint clrMask = 0u; + int n = min(numSeg, 24); + 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 { // Twig density scales with 3D arc length: ~one twig per 110 elmos, From 975086896dafb21be8153a2531de817ba7cb9755 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 02:55:43 +0200 Subject: [PATCH 52/59] working ghosts? --- .../Shaders/gfx_overdrive_cables.frag.glsl | 54 ++++++++--- .../Shaders/gfx_overdrive_cables.geom.glsl | 93 +++++++++++++------ LuaRules/Gadgets/gfx_overdrive_cables.lua | 76 ++++++++++++++- 3 files changed, 174 insertions(+), 49 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index 586204fd7f..3df329dd86 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -6,7 +6,8 @@ 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 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 — slot 0 is the FS-write probe target (debug only). layout (std430, binding = 6) coherent buffer cableCoverageBuffer { @@ -21,9 +22,8 @@ in DataGS { vec2 cableUV; vec2 timeData; vec4 gridData; - float spawnAlongMain; + float spawnAlongMain; // overloaded: main ribbon → lenPerSeg; twig → twig-along flat int gsSlot; - flat int gsNumSeg; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -239,6 +239,26 @@ void main() { if (along < witherFront) discard; } + // FAST GHOST PATH — orphaned-enemy edges (gridData.w = -1.0) skip all the + // cylinder-normal / lighting / bubble math below. Big perf win when the + // player has accumulated map-wide ghost coverage. Read LOS + coverage, + // decide, render 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; + // Currently-visible ground beneath a dead cable → discard (player is + // looking at empty terrain; the GS already cleared this segment's bit). + if (los0 >= ENEMY_LOS_CUT) discard; + uint cov0 = (gsSlot >= 0) ? cableCoverage[gsSlot].x : 0u; + if (cov0 == 0u) discard; + float capT0 = clamp(capacity / 100.0, 0.0, 1.0); + vec3 ghost0 = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT0); + if (isBranch > 0.5) ghost0 *= GHOST_BRANCH_DAMP; + float edgeFade0 = 1.0 - smoothstep(0.55, 0.90, t); + fragColor = vec4(ghost0 * edgeFade0, 1.0); + return; + } + // Cylinder cross-section normal that respects cable slope, derived from // the smoothly-interpolated cable tangent passed in by the GS. // @@ -309,17 +329,19 @@ void main() { float losState = texture(infoTex, losUV).r; float fullLOS = smoothstep(FULLLOS_LO, FULLLOS_HI, losState); - // Coverage bits are written by the GS once per cable per frame (cheap) - // rather than here in the FS (per-fragment = millions of atomics on - // thousand-cable matches). The FS only READS the coverage to decide - // ghost rendering. - // - // segBit = "any segment seen". We don't yet pass len-per-segment to - // the FS so per-segment fragment-side gating isn't possible — the - // whole cable shows as ghost when any of its bits are set, and hides - // when all bits clear. GS sets/clears per-segment so re-scout still - // dissolves the ghost correctly as the player walks the area. - uint segBit = 0xFFFFFFu; + // 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). @@ -339,7 +361,9 @@ void main() { if (isGhostEdge && losState >= ENEMY_LOS_CUT) discard; bool enemyOutOfLOS = (isOwnAlly < 0.5 && isOwnAlly > -0.5 && losState < ENEMY_LOS_CUT); - if (isGhostEdge || enemyOutOfLOS) { + 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; float capT2 = clamp(capacity / 100.0, 0.0, 1.0); diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl index fc6b0fc85b..99a53b5e94 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -19,6 +19,7 @@ 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 this cable is currently in LOS. Persistent across frames; never @@ -39,10 +40,13 @@ in DataVS { } dataIn[]; // Block must match `in DataGS` in gfx_overdrive_cables.frag.glsl exactly. -// gsLenPerSeg packed at the head of `cableUV` to avoid an extra varying: -// .x = along-elmos (existing), .y = cross [-1,1] (existing). -// Instead, pack lenPerSeg + slot into spawnAlongMain.zw — but that's vec2. -// Simpler: keep it as one float and check max-comp budget. +// 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; @@ -53,7 +57,6 @@ out DataGS { vec4 gridData; float spawnAlongMain; flat int gsSlot; - flat int gsNumSeg; }; //__ENGINEUNIFORMBUFFERDEFS__ @@ -119,11 +122,12 @@ const float CENTERLINE_CLEAR = 1.5; const float TWIG_CLEAR = 0.9; const float SIDE_CLEAR = 0.8; -float gOutBranch = 0.0; -float gOutSpawnAlong = 0.0; // set by emitTwig per-twig; main ribbon leaves at 0. -int gOutSlot = -1; // SSBO slot for this cable; carried into every emitVtx. -int gOutNumSeg = 0; // segment count for this cable. -float gOutLenPerSeg = 0.0; // along-elmos per segment; FS divides cableUV.x by this to get segIdx. +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) { @@ -136,7 +140,6 @@ void emitVtx(vec3 wp, vec3 tangent3D, vec2 cuv, gridData = grid; spawnAlongMain = gOutSpawnAlong; gsSlot = gOutSlot; - gsNumSeg = gOutNumSeg; // (vsTangent varying disabled — exceeded GS output budget on this hardware) gl_Position = cameraViewProj * vec4(wp, 1.0); EmitVertex(); @@ -432,6 +435,15 @@ void main() { 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; @@ -462,7 +474,7 @@ void main() { vec3 p3 = vec3(bj.x, hj, bj.y); len3D += distance(p3, prev3); float dy = hj - prev3.y; - if (j > 1) curv += abs(dy - prevDy); // sum |Δslope| as curvature proxy + if (j > 1) curv += abs(dy - prevDy); prevDy = dy; prev3 = p3; } @@ -475,38 +487,61 @@ void main() { // One global pull direction per cable: averaged dh across 5 chord anchors. // Per-segment probing was the source of zigzag — see cableArcDh comment. - float arcDh = cableArcDh(a, d, perpAB, lenAB); + // 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; - gOutNumSeg = numSeg; + // 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 (confirmed empty). Sampling along the actual - // wiggly path keeps reveal accurate even with arc bias on slopes. + // 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; - if (slot >= 0) { - uint setMask = 0u; - uint clrMask = 0u; + // 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); - 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)); + 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); } - 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 diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 77f012a4ce..111d9f3bbe 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -1893,6 +1893,17 @@ local function CableTreeCmd(cmd, line, words, playerID) 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 "") @@ -2411,10 +2422,13 @@ function OnCableTreeFull(data) -- 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 e.isOwnAlly then - if e.slot and e.slot >= 0 then + 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, @@ -2432,12 +2446,19 @@ function OnCableTreeFull(data) end -- If a fresh edge resurrects an old ghost (enemy rebuilt the same pylon - -- pair), drop the ghost — live takes over. The slot stays bound to the - -- live edge via slotByKey; bits accumulated under the ghost get inherited. + -- 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 @@ -2476,6 +2497,15 @@ function OnCableTreeFull(data) -- 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, the visual feedback + -- is desired). + -- enemy → no growth (you can't know when the enemy built it, + -- so a u=0-outward crop reads as "regrow on + -- every MST reroute" which is wrong). + -- resurrected ghost → no growth too (continuity of memory). + local af = (ownAlly and not (resurrectedKeys and resurrectedKeys[k])) + and frame or 0 existing[k] = { px = data.pxs[i], pz = data.pzs[i], cx = data.cxs[i], cz = data.czs[i], @@ -2483,7 +2513,7 @@ function OnCableTreeFull(data) flow = newFlow, eff = data.effs and data.effs[i] or 0, isOwnAlly = ownAlly, - appearFrame = frame, + appearFrame = af, witherFrame = nil, key = k, slot = slot, @@ -2615,6 +2645,38 @@ function gadget:GameFrame(n) if ghostNeedsRebuild then RebuildGhostVBO() end + + -- 5) Ghost cleanup pass — once every ~30 frames (~1Hz) walk the ghost + -- table and drop entries the local viewer has confirmed empty by + -- re-scouting. Without this, ghosts pile up forever and every dead + -- enemy cable still costs a GS+FS draw cycle even though all bits + -- have been cleared by the GS atomicAnd. + -- + -- Heuristic: 3-point LOS test (start, mid, end) for the local ally. + -- If all three points are currently in LOS, the player has clearly + -- swept the area and the cable is confirmed gone — free the slot. + -- Misses partial scouts (where only one endpoint is visible) but + -- keeps the test cheap. + if cableGhosts and (n % 30) == 0 and next(ghostEdges) then + local ally = spGetMyAllyTeamID() + local spec, fullView = spGetSpectatingState() + if not (spec or fullView) then + local removed = false + for k, e in pairs(ghostEdges) do + local mx, mz = (e.px + e.cx) * 0.5, (e.pz + e.cz) * 0.5 + local px, pz = e.px, e.pz + local cx, cz = e.cx, e.cz + if Spring.IsPosInLos(px, 0, pz, ally) + and Spring.IsPosInLos(mx, 0, mz, ally) + and Spring.IsPosInLos(cx, 0, cz, ally) then + ghostEdges[k] = nil + FreeSlot(k) + removed = true + end + end + if removed then ghostNeedsRebuild = true end + end + end end function gadget:DrawWorldPreUnit() @@ -2632,6 +2694,7 @@ function gadget:DrawWorldPreUnit() 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 for GS atomicOr writes -- and (slice 3) ghost-pass reads. Always bound when shader is active so @@ -2663,7 +2726,9 @@ function gadget:DrawWorldPreUnit() -- Ghost pass: orphaned enemy edges. Same shader, same SSBO; the FS uses -- gridData.w=-1.0 sentinel to force the ghost branch regardless of LOS. - if ghostVAO and numGhostVerts > 0 then + -- Hard-gated on cableGhosts so flipping the menu toggle off eliminates + -- the GS expansion + atomic ops + FS rasterisation cost entirely. + if cableGhosts and ghostVAO and numGhostVerts > 0 then ghostVAO:DrawArrays(GL.LINES, numGhostVerts) end @@ -2711,6 +2776,7 @@ function gadget:Initialize() gameTime = 0, bakeTime = 0, enableFlow = cableFlowMode and 1.0 or 0.0, + ghostsEnabled = cableGhosts and 1.0 or 0.0, }, }, "Cable Forward Shader") From 0bde458f184b719e1c926aa0db2d0819fd9d8071 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 10:08:17 +0200 Subject: [PATCH 53/59] ghost progress --- .../Shaders/gfx_overdrive_cables.frag.glsl | 52 ++++++--- .../Shaders/gfx_overdrive_cables.geom.glsl | 7 ++ LuaRules/Gadgets/gfx_overdrive_cables.lua | 104 ++++++++++-------- 3 files changed, 105 insertions(+), 58 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index 3df329dd86..863831031b 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -217,11 +217,16 @@ vec3 gridEfficiencyColor(float eff) { return hueToRgb(h / 255.0); } -// Ghost shading constants — flat memory render for unscouted enemy fragments -// that fall inside the seen-segment range. -const vec3 GHOST_BASE_LO = vec3(0.30); // capT = 0 -const vec3 GHOST_BASE_HI = vec3(0.55); // capT = 1 -const float GHOST_BRANCH_DAMP = 0.65; +// Ghost shading constants — translucent, slightly cool, gently shimmering +// "memory" render. Reads as a wisp rather than a flat painted line. +const vec3 GHOST_BASE_LO = vec3(0.32, 0.38, 0.46); // capT = 0 (cool grey) +const vec3 GHOST_BASE_HI = vec3(0.55, 0.66, 0.78); // capT = 1 (cool brighter) +const float GHOST_BRANCH_DAMP = 0.6; +const float GHOST_ALPHA_BASE = 0.55; // baseline opacity at the centerline +const float GHOST_SHIMMER_AMP = 0.18; // ±brightness from a slow sin pulse +const float GHOST_SHIMMER_HZ = 0.45; // pulses/sec +const float GHOST_EDGE_FADE_LO = 0.30; // wispier edge falloff than live +const float GHOST_EDGE_FADE_HI = 0.90; void main() { float v = cableUV.y; @@ -240,22 +245,43 @@ void main() { } // FAST GHOST PATH — orphaned-enemy edges (gridData.w = -1.0) skip all the - // cylinder-normal / lighting / bubble math below. Big perf win when the - // player has accumulated map-wide ghost coverage. Read LOS + coverage, - // decide, render flat ghost or discard. Nothing else. + // cylinder-normal / lighting / bubble math below. Read LOS + coverage, + // decide, render translucent shimmery 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; - // Currently-visible ground beneath a dead cable → discard (player is - // looking at empty terrain; the GS already cleared this segment's bit). if (los0 >= ENEMY_LOS_CUT) discard; uint cov0 = (gsSlot >= 0) ? cableCoverage[gsSlot].x : 0u; - if (cov0 == 0u) discard; + // 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; + float capT0 = clamp(capacity / 100.0, 0.0, 1.0); vec3 ghost0 = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT0); if (isBranch > 0.5) ghost0 *= GHOST_BRANCH_DAMP; - float edgeFade0 = 1.0 - smoothstep(0.55, 0.90, t); - fragColor = vec4(ghost0 * edgeFade0, 1.0); + + // Slow shimmer keyed off worldPos so neighbouring cables don't pulse + // in lockstep. ±18% brightness, ~half-Hz so it's "breathing" not + // "flickering". + float shim = 1.0 + GHOST_SHIMMER_AMP * sin( + gameTime * (6.2831853 * GHOST_SHIMMER_HZ) + + worldPos.x * 0.013 + worldPos.z * 0.017); + ghost0 *= shim; + + // Wispy edges: stronger smoothstep + alpha that follows it so the + // ribbon dissolves into transparency rather than hard-clipping. + float edgeFade0 = 1.0 - smoothstep(GHOST_EDGE_FADE_LO, GHOST_EDGE_FADE_HI, t); + float alpha = GHOST_ALPHA_BASE * edgeFade0; + fragColor = vec4(ghost0 * edgeFade0, alpha); return; } diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl index 99a53b5e94..1d6c7aff7e 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -425,6 +425,13 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, void main() { vec2 a = dataIn[0].vsWorldXZ; vec2 b = dataIn[1].vsWorldXZ; + // Normalize chord direction so parent/child swap (which can happen when + // the MST reroutes and re-orients the same edgeKey) doesn't change the + // wiggly path — otherwise live → ghost or ghost → live transitions + // "teleport" the cable to a different noise seed. + if (a.x > b.x || (a.x == b.x && a.y > b.y)) { + vec2 tmp = a; a = b; b = tmp; + } vec2 d = b - a; float lenAB = length(d); if (lenAB < 0.5) return; diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 111d9f3bbe..fa4a6fd7d2 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -2166,9 +2166,17 @@ local needsRebuild = false -- ghost itself retires after a re-scout-clear pass). local ghostEdges = {} 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 local ghostNeedsRebuild = false +-- 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, @@ -2328,23 +2336,30 @@ end local function RebuildGhostVBO() ghostNeedsRebuild = false local verts, vertCount = GenerateGhostTree() - if vertCount == 0 then - ghostVAO = nil - numGhostVerts = 0 - return - end - local vbo = gl.GetVBO(GL.ARRAY_BUFFER, false) - if not vbo then return end - vbo:Define(vertCount, { - { id = 0, name = "vertPos", size = 2 }, - { id = 1, name = "vertData", size = 3 }, - { id = 2, name = "vertGrid", size = 4 }, - { id = 3, name = "vertSlot", size = 1 }, - }) - vbo:Upload(verts) - ghostVAO = gl.GetVAO() - if ghostVAO then ghostVAO:AttachVertexBuffer(vbo) end numGhostVerts = vertCount + if vertCount == 0 then return end + + -- Reuse the existing VBO/VAO across rebuilds. Only re-Define when the + -- current capacity isn't enough; grow with headroom so small churn + -- (a couple of ghosts dying or resurrecting) doesn't trigger a Define + -- on every event. + if not ghostVBO then + ghostVBO = gl.GetVBO(GL.ARRAY_BUFFER, true) -- freqUpdated = true + if not ghostVBO then return end + end + if ghostVBOCapacity < vertCount then + local newCap = math.max(vertCount, ghostVBOCapacity * 2, 64) + ghostVBO:Define(newCap, { + { id = 0, name = "vertPos", size = 2 }, + { id = 1, name = "vertData", size = 3 }, + { id = 2, name = "vertGrid", size = 4 }, + { id = 3, name = "vertSlot", size = 1 }, + }) + ghostVBOCapacity = newCap + ghostVAO = gl.GetVAO() + if ghostVAO then ghostVAO:AttachVertexBuffer(ghostVBO) end + end + ghostVBO:Upload(verts) end -- Old generic angle clustering — kept commented as a reference for when we @@ -2646,34 +2661,35 @@ function gadget:GameFrame(n) RebuildGhostVBO() end - -- 5) Ghost cleanup pass — once every ~30 frames (~1Hz) walk the ghost - -- table and drop entries the local viewer has confirmed empty by - -- re-scouting. Without this, ghosts pile up forever and every dead - -- enemy cable still costs a GS+FS draw cycle even though all bits - -- have been cleared by the GS atomicAnd. - -- - -- Heuristic: 3-point LOS test (start, mid, end) for the local ally. - -- If all three points are currently in LOS, the player has clearly - -- swept the area and the cable is confirmed gone — free the slot. - -- Misses partial scouts (where only one endpoint is visible) but - -- keeps the test cheap. - if cableGhosts and (n % 30) == 0 and next(ghostEdges) then - local ally = spGetMyAllyTeamID() + -- 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 - for k, e in pairs(ghostEdges) do + 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 - local px, pz = e.px, e.pz - local cx, cz = e.cx, e.cz - if Spring.IsPosInLos(px, 0, pz, ally) + if Spring.IsPosInLos(e.px, 0, e.pz, ally) and Spring.IsPosInLos(mx, 0, mz, ally) - and Spring.IsPosInLos(cx, 0, cz, ally) then + 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 @@ -2715,21 +2731,19 @@ function gadget:DrawWorldPreUnit() gl.Culling(false) gl.DepthTest(GL.LEQUAL) gl.DepthMask(true) - -- Fully opaque output — every path writes alpha=1.0 (enemy ghost branch - -- is gone; out-of-LOS enemy fragments are discarded). Disable blending - -- so depth-tested cables compose cleanly against the world. - gl.Blending(false) - - -- GL_LINES: every 2 verts form one cable; the geometry shader expands - -- them into a triangle_strip ribbon. + -- 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. Same shader, same SSBO; the FS uses - -- gridData.w=-1.0 sentinel to force the ghost branch regardless of LOS. - -- Hard-gated on cableGhosts so flipping the menu toggle off eliminates - -- the GS expansion + atomic ops + FS rasterisation cost entirely. + -- Ghost pass: orphaned enemy edges, semi-transparent shimmery render. + -- Render with DepthMask off so they don't occlude live cables behind + -- them, and so re-draws on top of terrain blend correctly 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 GS atomicOr writes visible to subsequent SSBO consumers (slice 3 From a1c3a9313c9edbc3f65e59667dbac00c41e35046 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 10:20:51 +0200 Subject: [PATCH 54/59] perfection --- .../Shaders/gfx_overdrive_cables.frag.glsl | 44 +++++------ LuaRules/Gadgets/gfx_overdrive_cables.lua | 77 +++++++++++++++---- 2 files changed, 78 insertions(+), 43 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index 863831031b..66494bb1d7 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -217,15 +217,13 @@ vec3 gridEfficiencyColor(float eff) { return hueToRgb(h / 255.0); } -// Ghost shading constants — translucent, slightly cool, gently shimmering -// "memory" render. Reads as a wisp rather than a flat painted line. -const vec3 GHOST_BASE_LO = vec3(0.32, 0.38, 0.46); // capT = 0 (cool grey) -const vec3 GHOST_BASE_HI = vec3(0.55, 0.66, 0.78); // capT = 1 (cool brighter) -const float GHOST_BRANCH_DAMP = 0.6; -const float GHOST_ALPHA_BASE = 0.55; // baseline opacity at the centerline -const float GHOST_SHIMMER_AMP = 0.18; // ±brightness from a slow sin pulse -const float GHOST_SHIMMER_HZ = 0.45; // pulses/sec -const float GHOST_EDGE_FADE_LO = 0.30; // wispier edge falloff than live +// 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() { @@ -265,23 +263,15 @@ void main() { } 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 = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT0); + vec3 ghost0 = GHOST_COLOR * (1.0 - GHOST_CAP_TINT + GHOST_CAP_TINT * 2.0 * capT0); if (isBranch > 0.5) ghost0 *= GHOST_BRANCH_DAMP; - - // Slow shimmer keyed off worldPos so neighbouring cables don't pulse - // in lockstep. ±18% brightness, ~half-Hz so it's "breathing" not - // "flickering". - float shim = 1.0 + GHOST_SHIMMER_AMP * sin( - gameTime * (6.2831853 * GHOST_SHIMMER_HZ) - + worldPos.x * 0.013 + worldPos.z * 0.017); - ghost0 *= shim; - - // Wispy edges: stronger smoothstep + alpha that follows it so the - // ribbon dissolves into transparency rather than hard-clipping. float edgeFade0 = 1.0 - smoothstep(GHOST_EDGE_FADE_LO, GHOST_EDGE_FADE_HI, t); - float alpha = GHOST_ALPHA_BASE * edgeFade0; - fragColor = vec4(ghost0 * edgeFade0, alpha); + fragColor = vec4(ghost0, GHOST_ALPHA_BASE * edgeFade0); return; } @@ -392,11 +382,13 @@ void main() { 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 = mix(GHOST_BASE_LO, GHOST_BASE_HI, capT2); + 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(0.55, 0.90, t); - fragColor = vec4(ghost * edgeFade, 1.0); + float edgeFade = 1.0 - smoothstep(GHOST_EDGE_FADE_LO, GHOST_EDGE_FADE_HI, t); + fragColor = vec4(ghost, GHOST_ALPHA_BASE * edgeFade); return; } diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index fa4a6fd7d2..5857804643 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -2424,6 +2424,16 @@ function OnCableTreeFull(data) local frame = Spring.GetGameFrame() local existing = edgesByAllyTeam[ally] or {} + -- Local viewer's allyTeam — used to decide whether the player witnessed + -- an enemy edge being built or destroyed (then we play the live anim) or + -- not (then we go straight to ghost/silent removal). + local myAlly = spGetMyAllyTeamID() + local function midInLOS(px, pz, cx, cz) + if ownAlly then return true end + local mx, mz = (px + cx) * 0.5, (pz + cz) * 0.5 + return Spring.IsPosInLos(mx, 0, mz, myAlly) + end + -- Build a fast lookup of incoming keys. local incoming = {} for i = 1, data.edgeCount do @@ -2443,16 +2453,24 @@ function OnCableTreeFull(data) for k, e in pairs(existing) do if not incoming[k] and not e.witherFrame then if not e.isOwnAlly then - 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 + -- 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 midInLOS(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 - existing[k] = nil else e.witherFrame = frame end @@ -2486,6 +2504,13 @@ function OnCableTreeFull(data) 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. @@ -2513,14 +2538,19 @@ function OnCableTreeFull(data) -- 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, the visual feedback - -- is desired). - -- enemy → no growth (you can't know when the enemy built it, - -- so a u=0-outward crop reads as "regrow on - -- every MST reroute" which is wrong). - -- resurrected ghost → no growth too (continuity of memory). - local af = (ownAlly and not (resurrectedKeys and resurrectedKeys[k])) - and frame or 0 + -- 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 midInLOS(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], @@ -2630,10 +2660,23 @@ function gadget:GameFrame(n) 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 From f406a7cf123b839077c008e8d0b33056c0113894 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 10:44:49 +0200 Subject: [PATCH 55/59] awesome 1 --- .../Shaders/gfx_overdrive_cables.frag.glsl | 5 +- .../Shaders/gfx_overdrive_cables.geom.glsl | 37 +++++++--- LuaRules/Gadgets/gfx_overdrive_cables.lua | 74 ++++++++++--------- 3 files changed, 69 insertions(+), 47 deletions(-) diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl index 66494bb1d7..7a791623fd 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.frag.glsl @@ -9,7 +9,8 @@ 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 — slot 0 is the FS-write probe target (debug only). +// 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[]; }; @@ -244,7 +245,7 @@ void main() { // 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 shimmery ghost or discard. Nothing else. + // 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; diff --git a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl index 1d6c7aff7e..a352a6febe 100644 --- a/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl +++ b/LuaRules/Gadgets/Shaders/gfx_overdrive_cables.geom.glsl @@ -22,9 +22,9 @@ 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 this cable is currently in LOS. Persistent across frames; never -// cleared in slice 1 (slice 3 will clear bits during the ghost pass when -// the player's LOS confirms the area is empty). +// `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`. @@ -204,11 +204,20 @@ vec2 arcBiasedCenter(vec2 a, vec2 d, vec2 perpAB, float t, float lenAB, float dh // 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); - return base + perpAB * n; + 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. @@ -423,15 +432,14 @@ void emitTwig(vec2 a, vec2 d, vec2 perpAB, } 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; - // Normalize chord direction so parent/child swap (which can happen when - // the MST reroutes and re-orients the same edgeKey) doesn't change the - // wiggly path — otherwise live → ghost or ghost → live transitions - // "teleport" the cable to a different noise seed. - if (a.x > b.x || (a.x == b.x && a.y > b.y)) { - vec2 tmp = a; a = b; b = tmp; - } vec2 d = b - a; float lenAB = length(d); if (lenAB < 0.5) return; @@ -455,7 +463,12 @@ void main() { 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); - float seed = a.x * 0.137 + a.y * 0.781 + b.x * 0.293 + b.y * 0.461; + // 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 diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 5857804643..25574311a3 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -356,20 +356,21 @@ local cableDetail = readDetailFromConfig() local cableEnabled = cableDetail ~= DETAIL_OFF local cablePerf = false local cableFlowMode = cableDetail == DETAIL_FULL --- Ghost rendering: when on, the GS samples LOS at each cable segment center --- and atomicOr's the per-edge coverage SSBO. A separate ghost pass (slice 3) --- consumes those bits to render last-seen segments of dead/orphaned enemy --- cables. Slice 1 only fills the SSBO so we can verify the bit accumulation; --- the live render is unchanged. +-- 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 -- --------------------------------------------------------------------------- --- Per-edge coverage SSBO (slice 1: bits set by GS, not yet consumed) +-- 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. The slot --- index is allocated CPU-side per edgeKey and freed when the edge has no --- live presence and no remaining ghost coverage. +-- 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): @@ -384,7 +385,6 @@ local cableGhosts = (Spring.GetConfigInt("OverdriveCableGhosts", 1) or 1) ~= 0 local COVERAGE_MAX_SLOTS = 4096 local coverageSSBO -- gl.GetVBO(GL.SHADER_STORAGE_BUFFER); allocated in Initialize local slotByKey = {} -- edgeKey -> slot -local keyBySlot = {} -- slot -> edgeKey local freeSlots = {} -- stack of recycled slot IDs local nextSlot = 0 -- next never-allocated slot @@ -400,7 +400,6 @@ local function AllocSlot(edgeKey) nextSlot = nextSlot + 1 end slotByKey[edgeKey] = s - keyBySlot[s] = edgeKey -- 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. @@ -411,14 +410,14 @@ local function FreeSlot(edgeKey) local s = slotByKey[edgeKey] if not s then return end slotByKey[edgeKey] = nil - keyBySlot[s] = 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. Args: (data, attribIdx, - -- elemOffset). attribIdx=0 is our only attribute; elemOffset places the - -- single-element write at slot s. + -- 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}, 0, s) + coverageSSBO:Upload({0, 0, 0, 0}, nil, s, 1, 4) end end @@ -2520,13 +2519,22 @@ function OnCableTreeFull(data) e.bubbleAnchorTime = nowSec e.bubbleSpeed = flowToSpeed(newFlow, data.caps[i]) - e.capacity = data.caps[i] - e.flow = newFlow - e.eff = data.effs and data.effs[i] or 0 + -- 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 midInLOS(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 - -- positions are stable for unchanged edges; assign anyway in case parent moved - e.px, e.pz = data.pxs[i], data.pzs[i] - e.cx, e.cz = data.cxs[i], data.czs[i] -- 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 @@ -2755,9 +2763,8 @@ function gadget:DrawWorldPreUnit() 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 for GS atomicOr writes - -- and (slice 3) ghost-pass reads. Always bound when shader is active so - -- we don't have to manage a separate ghost program. + -- 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 @@ -2779,20 +2786,21 @@ function gadget:DrawWorldPreUnit() gl.Blending(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA) cableVAO:DrawArrays(GL.LINES, numCableVerts) - -- Ghost pass: orphaned enemy edges, semi-transparent shimmery render. - -- Render with DepthMask off so they don't occlude live cables behind - -- them, and so re-draws on top of terrain blend correctly without - -- writing depth that would clamp later geometry. + -- 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 GS atomicOr writes visible to subsequent SSBO consumers (slice 3 - -- ghost pass) and to CPU-side Download. Without this barrier the writes - -- live in the shader-store cache and never become coherent with later - -- map/read operations on the same buffer. + -- 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 From b2f6cf4bf80ab13750f05e4eb54b5b2133f634fc Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 10:57:09 +0200 Subject: [PATCH 56/59] update ghost capacity --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 25574311a3..7d4ac1fd5e 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -2423,14 +2423,24 @@ function OnCableTreeFull(data) local frame = Spring.GetGameFrame() local existing = edgesByAllyTeam[ally] or {} - -- Local viewer's allyTeam — used to decide whether the player witnessed - -- an enemy edge being built or destroyed (then we play the live anim) or - -- not (then we go straight to ghost/silent removal). + -- 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 midInLOS(px, pz, cx, cz) + 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 - return Spring.IsPosInLos(mx, 0, mz, myAlly) + 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. @@ -2456,7 +2466,7 @@ function OnCableTreeFull(data) -- 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 midInLOS(e.px, e.pz, e.cx, e.cz) then + 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 @@ -2526,7 +2536,7 @@ function OnCableTreeFull(data) -- 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 midInLOS(data.pxs[i], data.pzs[i], data.cxs[i], data.czs[i]) + 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 @@ -2554,7 +2564,7 @@ function OnCableTreeFull(data) local af if resurrectedKeys and resurrectedKeys[k] then af = 0 - elseif ownAlly or midInLOS(data.pxs[i], data.pzs[i], data.cxs[i], data.czs[i]) then + elseif ownAlly or anyInLOS(data.pxs[i], data.pzs[i], data.cxs[i], data.czs[i]) then af = frame else af = 0 From 961b8cc20f9d3bd4821134c4ea18d094b754a13a Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 11:05:11 +0200 Subject: [PATCH 57/59] instant apply on menu toggle --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 46 ++++++++++++----------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 7d4ac1fd5e..942ace1a59 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -2336,28 +2336,10 @@ local function RebuildGhostVBO() ghostNeedsRebuild = false local verts, vertCount = GenerateGhostTree() numGhostVerts = vertCount - if vertCount == 0 then return end - - -- Reuse the existing VBO/VAO across rebuilds. Only re-Define when the - -- current capacity isn't enough; grow with headroom so small churn - -- (a couple of ghosts dying or resurrecting) doesn't trigger a Define - -- on every event. - if not ghostVBO then - ghostVBO = gl.GetVBO(GL.ARRAY_BUFFER, true) -- freqUpdated = true - if not ghostVBO then return end - end - if ghostVBOCapacity < vertCount then - local newCap = math.max(vertCount, ghostVBOCapacity * 2, 64) - ghostVBO:Define(newCap, { - { id = 0, name = "vertPos", size = 2 }, - { id = 1, name = "vertData", size = 3 }, - { id = 2, name = "vertGrid", size = 4 }, - { id = 3, name = "vertSlot", size = 1 }, - }) - ghostVBOCapacity = newCap - ghostVAO = gl.GetVAO() - if ghostVAO then ghostVAO:AttachVertexBuffer(ghostVBO) end - end + 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 @@ -2757,6 +2739,10 @@ function gadget:GameFrame(n) 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() @@ -2881,6 +2867,22 @@ function gadget:Initialize() 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 From b8f0cef327773b4cde4eed32c2ae44bcceaf3055 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 11:44:16 +0200 Subject: [PATCH 58/59] fix player switching --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 48 ++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index 942ace1a59..a297c0ab25 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -2431,6 +2431,17 @@ function OnCableTreeFull(data) 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). @@ -2443,7 +2454,7 @@ function OnCableTreeFull(data) -- ghost feature existed. for k, e in pairs(existing) do if not incoming[k] and not e.witherFrame then - if not e.isOwnAlly 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 @@ -2887,6 +2898,41 @@ function gadget:Initialize() 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 From 0d015cb45c15ef99ee92ff0a807374abb527eca8 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 1 May 2026 11:47:32 +0200 Subject: [PATCH 59/59] fix lua error on cabling enable/disable --- LuaRules/Gadgets/gfx_overdrive_cables.lua | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/LuaRules/Gadgets/gfx_overdrive_cables.lua b/LuaRules/Gadgets/gfx_overdrive_cables.lua index a297c0ab25..56b0e93b31 100644 --- a/LuaRules/Gadgets/gfx_overdrive_cables.lua +++ b/LuaRules/Gadgets/gfx_overdrive_cables.lua @@ -363,6 +363,17 @@ local cableFlowMode = cableDetail == DETAIL_FULL -- 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 -- --------------------------------------------------------------------------- @@ -2163,12 +2174,12 @@ local needsRebuild = false -- 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). -local ghostEdges = {} +-- (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 -local ghostNeedsRebuild = false -- Rolling cleanup cursor: each tick we scan a small slice of ghostEdges -- (3-point IsPosInLos) instead of scanning all of them every 30 frames.