diff --git a/gamedata/modrules.lua b/gamedata/modrules.lua index f568fc5ebdf..5a68ccc28e6 100644 --- a/gamedata/modrules.lua +++ b/gamedata/modrules.lua @@ -58,7 +58,7 @@ local modrules = { los = { losMipLevel = 3, -- Controls the resolution of the LOS calculations. A higher value means lower resolution but increased performance. An increase by one level means half the resolution of the LOS map in both x and y direction. Must be between 0 and 6 inclusive. airMipLevel = 4, -- Controls the resolution of the LOS vs. aircraft calculations. A higher value means lower resolution but increased performance. An increase by one level means half the resolution of the air-LOS map in both x and y direction. Must be between 0 and 30 inclusive. [1] - jK describe for you what the value means. - radarMipLevel = 3, -- Controls the resolution of the radar. See description of airMipLevel for details. + radarMipLevel = 2, -- Controls the resolution of the radar. See description of airMipLevel for details. }, }, diff --git a/luarules/callins/synthetic_callins.lua b/luarules/callins/synthetic_callins.lua index c35dc132f13..9992b0af1b8 100644 --- a/luarules/callins/synthetic_callins.lua +++ b/luarules/callins/synthetic_callins.lua @@ -9,15 +9,20 @@ -- event system, in many cases, so code built on top of these foundations needs -- to keep up with the times and adapt as the gaps fill in from the other side. -- --- This also produces two types of summary view of a base callin: --- 1. *Post.* This is a mark-and-sweep pattern to update dirtied IDs. --- 2. *Total.* This is an accumulator with a hook for custom results. +-- This also produces a few types of "summary views" over a base callin: +-- - *Post.* This is a mark-and-sweep pattern to update dirtied IDs. +-- - *Total.* This is an accumulator with a hook for custom results. +-- - *Transition.* Tracks state changes, holding a latch and ignoring loops. -- -- Adding a new callin: -- 1. Add the callin's envs and subscriptions to syntheticCallins. --- 2. If producing a summary view, add to syntheticCallinSummaries. +-- 2. If producing a summary view, add to one of the following sets: +-- `syntheticCallinSummaries`: -- This produces both the Post and Total callins and -- adds the custom GG.Accumulate hook later during install. +-- `syntheticCallinTransitions`: +-- This produces only the Post callin and collects state changes +-- to be raised later only if they do not resolve subframe, eg A->B->A. -- 3. If the callin tracks more state, add it to syntheticCallinUpdate. -- 4. Add the callin's implementation (and locals) to the Dispatch section. -- 5. Add the callin to the handler in the Install section. @@ -33,26 +38,31 @@ local env = Script.GetSynced() and "synced" or "unsynced" -- can become unhooked whenever no addon happens to subscribe to their base. local syntheticCallins = { shared = { - MetaUnitAdded = { 'UnitGiven', 'UnitCreated' }, - MetaUnitRemoved = { 'UnitTaken', 'UnitDestroyed' }, + MetaUnitAdded = { "UnitGiven", "UnitCreated" }, + MetaUnitRemoved = { "UnitTaken", "UnitDestroyed" }, }, synced = { - UnitAutoTargetRange = { 'AllowWeaponTarget' }, - UnitBuildStepPost = { 'GameFramePost', 'AllowUnitBuildStep' }, - FeatureBuildStepPost = { 'GameFramePost', 'AllowFeatureBuildStep' }, - UnitBuildStepTotal = { 'GameFramePost', 'AllowUnitBuildStep' }, - FeatureBuildStepTotal = { 'GameFramePost', 'AllowFeatureBuildStep' }, + UnitAutoTargetRange = { "AllowWeaponTarget" }, + UnitBuildStepPost = { "GameFramePost", "AllowUnitBuildStep" }, + FeatureBuildStepPost = { "GameFramePost", "AllowFeatureBuildStep" }, + UnitBuildStepTotal = { "GameFramePost", "AllowUnitBuildStep" }, + FeatureBuildStepTotal = { "GameFramePost", "AllowFeatureBuildStep" }, + UnitIdlePost = { "GameFramePost", "UnitIdle", "UnitCommand", "UnitTaken", "UnitDestroyed" }, -- See modules/unit_idle_states.lua. }, unsynced = {}, } local syntheticCallinSummaries = { - UnitBuildStep = true, + UnitBuildStep = true, FeatureBuildStep = true, } +local syntheticCallinTransitions = { + UnitIdle = true, +} + -- The engine does not know these names, so `Script.UpdateCallIn` is a no-op. -- We have to handle dropping tracked state, etc., during updates on our own. -- Update handlers receive the gadgetHandler to read their subscriber lists. @@ -67,13 +77,13 @@ local callinNames = table.keys(syntheticCallinHold) local callinHoldSummary = {} for name, callinHolds in pairs(syntheticCallinHold) do for _, callin in ipairs(callinHolds) do - local listName = callin .. 'List' + local listName = callin .. "List" local holders = callinHoldSummary[listName] if not holders then holders = {} callinHoldSummary[listName] = holders end - holders[#holders + 1] = name .. 'List' + holders[#holders + 1] = name .. "List" end end @@ -121,6 +131,9 @@ end ---@class SummaryActive ---@field [1] true? whether accumulating totals +---Sticky-state per marked ID, kept while active. +---@alias SummaryLatched table + local function createSummary(callinName) if not syntheticCallinSummaries[callinName] then return @@ -134,7 +147,7 @@ local function createSummary(callinName) marks[callinName] = { marked = marked, list = list, count = count, totals = totals, active = active, stop = stop } - accumulate['Accumulate' .. callinName] = function(id, amount) + accumulate["Accumulate" .. callinName] = function(id, amount) -- Call sites must not accumulate when not subscribed. local n = count[1] if not n then @@ -154,8 +167,8 @@ local function createSummary(callinName) end -- Both summary views share updates so must handle updating together. - local postList = callinName .. 'PostList' - local totalList = callinName .. 'TotalList' + local postList = callinName .. "PostList" + local totalList = callinName .. "TotalList" local function update(gh) if #gh[totalList] > 0 then active[1] = true @@ -171,8 +184,36 @@ local function createSummary(callinName) stop() end end - syntheticCallinUpdate[callinName .. 'Post'] = update - syntheticCallinUpdate[callinName .. 'Total'] = update + syntheticCallinUpdate[callinName .. "Post"] = update + syntheticCallinUpdate[callinName .. "Total"] = update +end + +local function createTransition(callinName) + if not syntheticCallinTransitions[callinName] then + return + end + + ---@type SummaryMarked, SummaryList, SummaryCount + local marked, list, count = {}, {}, table.new(1, 0) + ---@type SummaryLatched + local latched = {} + local stop = makeStopMarking(marked, list, count) + + marks[callinName] = { marked = marked, list = list, count = count, latched = latched, stop = stop } + + local postList = callinName .. "PostList" + syntheticCallinUpdate[callinName .. "Post"] = function(gh) + if #gh[postList] > 0 then + count[1] = count[1] or 0 + else + stop() + -- After the last subscriber drops, any late-subscribers cannot trust + -- the latched states anymore, so the states also must be dropped. + for id in pairs(latched) do + latched[id] = nil + end + end + end end ---A summary's marking state. Callins from non-matching envs get empty tables. @@ -185,8 +226,8 @@ end local function getMarks(baseName) local mark = marks[baseName] if not mark then - if not syntheticCallinSummaries[baseName] then - error('synthetic_callins: no such summary: ' .. tostring(baseName)) + if not syntheticCallinSummaries[baseName] and not syntheticCallinTransitions[baseName] then + error("synthetic_callins: no such summary: " .. tostring(baseName)) end return {}, {}, {}, {}, {} end @@ -198,11 +239,16 @@ local function getMarksUnsafe(baseName) return mark.marked, mark.list, mark.count, mark.totals, mark.active end +---@return SummaryLatched latched +local function getLatchUnsafe(baseName) + return marks[baseName].latched +end + local function createSweep(callinName) local marked, list, countBox, totals, activeBox = getMarksUnsafe(callinName) local values = {} - local postName, totalName = callinName .. 'Post', callinName .. 'Total' - local postListName, totalListName = postName .. 'List', totalName .. 'List' + local postName, totalName = callinName .. "Post", callinName .. "Total" + local postListName, totalListName = postName .. "List", totalName .. "List" return function(handler) local count = countBox[1] @@ -248,11 +294,63 @@ local function createSweep(callinName) end end +---We distrust the engine's `:UnitIdle` callin so read the unit queue directly. +---See unit_idle_states.lua for detail on unit behaviors, namely, "idle tasks". +local function createUnitIdleSweep() + local marked, list, countBox = getMarksUnsafe("UnitIdle") + local latched = getLatchUnsafe("UnitIdle") + local transitions, states = {}, {} + + local VFSMODE = Spring.IsDevLuaEnabled() and VFS.RAW_FIRST or VFS.ZIP_ONLY + local isIdle = VFS.Include("modules/unit_idle_states.lua", nil, VFSMODE).IsIdle + + local spGetUnitDefID = Spring.GetUnitDefID + local spGetUnitIsDead = Spring.GetUnitIsDead + + return function(handler) + local count = countBox[1] + if not count or count == 0 then + return + end + countBox[1] = 0 + + -- Clear marks first so subscribers that throw do not leave any marks. + -- Gathering the batch up front also lets subscribers re-mark safely. + local n = 0 + for i = 1, count do + local unitID = list[i] + marked[unitID] = nil + + -- Units can be finalized between mark and sweep. Ignore dying units. + local unitDefID = spGetUnitDefID(unitID) + if unitDefID and spGetUnitIsDead(unitID) == false then + local idle = isIdle(unitID, unitDefID) + if idle ~= (latched[unitID] == true) then + latched[unitID] = idle or nil + n = n + 1 + transitions[n] = unitID + states[n] = idle + end + else + latched[unitID] = nil + end + end + + local postList = handler.UnitIdlePostList + for i = 1, n do + local unitID, idled = transitions[i], states[i] + for _, g in ipairs(postList) do + g:UnitIdlePost(unitID, idled) + end + end + end +end + -------------------------------------------------------------------------------- -- Dispatch ------------------------------------------------------------------ -- -- Callin implementations attach to the gadgetHandler in the Install section. --- +-- -- - UnitAutoTargetRange has its base implementation in gadgets.lua, instead. local callins = {} @@ -274,11 +372,14 @@ end -- Synced environment if Script.GetSynced() then - createSummary('UnitBuildStep') - callins.SweepUnitBuildStep = createSweep('UnitBuildStep') + createSummary("UnitBuildStep") + callins.SweepUnitBuildStep = createSweep("UnitBuildStep") + + createSummary("FeatureBuildStep") + callins.SweepFeatureBuildStep = createSweep("FeatureBuildStep") - createSummary('FeatureBuildStep') - callins.SweepFeatureBuildStep = createSweep('FeatureBuildStep') + createTransition("UnitIdle") + callins.SweepUnitIdle = createUnitIdleSweep() end -- Unsynced environment @@ -310,7 +411,7 @@ local function install(handler) end end - handler.MetaUnitAdded = callins.MetaUnitAdded + handler.MetaUnitAdded = callins.MetaUnitAdded handler.MetaUnitRemoved = callins.MetaUnitRemoved -- Wrap multi-env dispatchers for single-env synthetic callins at install @@ -320,11 +421,15 @@ local function install(handler) local gameFramePost = handler.GameFramePost local sweepUnitBuildStep = callins.SweepUnitBuildStep local sweepFeatureBuildStep = callins.SweepFeatureBuildStep + local sweepUnitIdle = callins.SweepUnitIdle + function handler:GameFramePost(frameNum) tracy.ZoneBeginN("G:GameFramePostSummary") sweepUnitBuildStep(self) sweepFeatureBuildStep(self) + sweepUnitIdle(self) tracy.ZoneEnd() + return gameFramePost(self, frameNum) end @@ -344,8 +449,8 @@ end ---Synthetic callin registry and dispatch for gadgets.lua. ---@class SyntheticCallinsAPI local synthetic = { - install = install, - getMarks = getMarks, + install = install, + getMarks = getMarks, callinNames = callinNames, } diff --git a/luarules/gadgets.lua b/luarules/gadgets.lua index 941d10c00c1..51365cb3703 100644 --- a/luarules/gadgets.lua +++ b/luarules/gadgets.lua @@ -461,10 +461,23 @@ end -- Synthetic callins -- -- The game injects some of its own callins into the engine-driven event system: -local synthetic = VFS.Include(SCRIPT_DIR .. 'callins/synthetic_callins.lua', nil, VFSMODE) ---@type SyntheticCallinsAPI +local synthetic = VFS.Include(SCRIPT_DIR .. "callins/synthetic_callins.lua", nil, VFSMODE) ---@type SyntheticCallinsAPI -local unitStepMarked, unitStepList, unitStepCount, unitStepTotals, unitStepActive = synthetic.getMarks('UnitBuildStep') -local featureStepMarked, featureStepList, featureStepCount, featureStepTotals, featureStepActive = synthetic.getMarks('FeatureBuildStep') +-- stylua: ignore start +local unitStepMarked, unitStepList, unitStepCount, unitStepTotals, unitStepActive = synthetic.getMarks("UnitBuildStep") +local featureStepMarked, featureStepList, featureStepCount, featureStepTotals, featureStepActive = synthetic.getMarks("FeatureBuildStep") +local unitIdleMarked, unitIdleList, unitIdleCount = synthetic.getMarks("UnitIdle") +-- stylua: ignore end + +local function markIdle(unitID) + local idleCount = unitIdleCount[1] + if idleCount and not unitIdleMarked[unitID] then + unitIdleMarked[unitID] = true + idleCount = idleCount + 1 + unitIdleCount[1] = idleCount + unitIdleList[idleCount] = unitID + end +end -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- @@ -2147,10 +2160,10 @@ end function gadgetHandler:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam, weaponDefID) tracy.ZoneBeginN("G:UnitDestroyed") self:MetaUnitRemoved(unitID, unitDefID, unitTeam) - for _, g in ipairs(self.UnitDestroyedList) do g:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam, weaponDefID) end + markIdle(unitID) tracy.ZoneEnd() return end @@ -2176,6 +2189,7 @@ function gadgetHandler:UnitIdle(unitID, unitDefID, unitTeam) for _, g in ipairs(self.UnitIdleList) do g:UnitIdle(unitID, unitDefID, unitTeam) end + markIdle(unitID) tracy.ZoneEnd() return end @@ -2267,10 +2281,10 @@ end function gadgetHandler:UnitTaken(unitID, unitDefID, unitTeam, newTeam) self:MetaUnitRemoved(unitID, unitDefID, unitTeam) - for _, g in ipairs(self.UnitTakenList) do g:UnitTaken(unitID, unitDefID, unitTeam, newTeam) end + markIdle(unitID) return end @@ -2299,6 +2313,7 @@ function gadgetHandler:UnitCommand( for _, g in ipairs(self.UnitCommandList) do g:UnitCommand(unitID, unitDefID, unitTeam, cmdId, cmdParams, cmdOpts, cmdTag, playerID, fromSynced, fromLua) end + markIdle(unitID) tracy.ZoneEnd() return end diff --git a/luarules/gadgets/ai_ruins.lua b/luarules/gadgets/ai_ruins.lua index d5ad5ace0c5..e9333ad0375 100644 --- a/luarules/gadgets/ai_ruins.lua +++ b/luarules/gadgets/ai_ruins.lua @@ -254,6 +254,12 @@ function getNearestBlocker(x, z) return math.sqrt(lowestDist) end +-- CreateUnit does not snap; Pos2BuildPos uses even vs odd grid from footprint parity. +local function createSnappedUnit(defID, x, y, z, facing, teamID) + x, y, z = Spring.Pos2BuildPos(defID, x, y, z, facing) + return Spring.CreateUnit(defID, x, y, z, facing, teamID) +end + local function spawnRuin(ruin, posx, posy, posz, blueprintTierLevel) local swapXandY, flipX, flipZ, rotation = randomlyRotateBlueprint() local mirrored, mirroredDirection, xOffset, zOffset @@ -285,16 +291,11 @@ local function spawnRuin(ruin, posx, posy, posz, blueprintTierLevel) local nonscavname = string.gsub(name, "_scav", "") local r = math.random(1, 100) if r < 40 and UnitDefNames[nonscavname] then - local posy = - Spring.GetGroundHeight(posx + (xOffset * flipX * mirrorX), posz + (zOffset * flipZ * mirrorZ)) - local unit = Spring.CreateUnit( - UnitDefNames[nonscavname].id, - posx + (xOffset * flipX * mirrorX), - posy, - posz + (zOffset * flipZ * mirrorZ), - (building.direction + rotation + mirrorRotation) % 4, - GaiaTeamID - ) + local facing = (building.direction + rotation + mirrorRotation) % 4 + local bx = posx + (xOffset * flipX * mirrorX) + local bz = posz + (zOffset * flipZ * mirrorZ) + local posy = Spring.GetGroundHeight(bx, bz) + local unit = createSnappedUnit(UnitDefNames[nonscavname].id, bx, posy, bz, facing, GaiaTeamID) if unit then local radarRange = UnitDefs[building.unitDefID].radarDistance local canMove = UnitDefs[building.unitDefID].canMove @@ -371,7 +372,8 @@ local function SpawnMexes(mexSpots) if canBuildHere then local mex = mexesList[math.random(1, #mexesList)] - local unit = Spring.CreateUnit(UnitDefNames[mex].id, posx, posy, posz, math.random(0, 3), GaiaTeamID) + local facing = math.random(0, 3) + local unit = createSnappedUnit(UnitDefNames[mex].id, posx, posy, posz, facing, GaiaTeamID) if unit then Spring.SetUnitNeutral(unit, true) Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0) @@ -398,6 +400,11 @@ local function SpawnGeos(geoSpots) geosList = seaGeosList end + local geo = geosList[math.random(1, #geosList)] + local defID = UnitDefNames[geo].id + local facing = math.random(0, 3) + posx, posy, posz = Spring.Pos2BuildPos(defID, posx, posy, posz, facing) + local radius = 32 local canBuildHere = positionCheckLibrary.VisibilityCheckEnemy( posx, @@ -422,8 +429,7 @@ local function SpawnGeos(geoSpots) end if canBuildHere then - local geo = geosList[math.random(1, #geosList)] - local unit = Spring.CreateUnit(UnitDefNames[geo].id, posx, posy, posz, math.random(0, 3), GaiaTeamID) + local unit = createSnappedUnit(defID, posx, posy, posz, facing, GaiaTeamID) if unit then Spring.SetUnitNeutral(unit, true) Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0) @@ -482,14 +488,9 @@ local function SpawnMexGeoRandomStructures() if canBuildHere then local defence = defencesList[math.random(1, #defencesList)] - local unit = Spring.CreateUnit( - UnitDefNames[defence].id, - posx2, - posy2, - posz2, - math.random(0, 3), - GaiaTeamID - ) + local facing = math.random(0, 3) + local unit = + createSnappedUnit(UnitDefNames[defence].id, posx2, posy2, posz2, facing, GaiaTeamID) if unit then Spring.SetUnitNeutral(unit, true) Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0) @@ -547,14 +548,9 @@ local function SpawnMexGeoRandomStructures() if canBuildHere then local defence = defencesList[math.random(1, #defencesList)] - local unit = Spring.CreateUnit( - UnitDefNames[defence].id, - posx2, - posy2, - posz2, - math.random(0, 3), - GaiaTeamID - ) + local facing = math.random(0, 3) + local unit = + createSnappedUnit(UnitDefNames[defence].id, posx2, posy2, posz2, facing, GaiaTeamID) if unit then Spring.SetUnitNeutral(unit, true) Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0) @@ -609,8 +605,8 @@ local function SpawnRandomStructures() if canBuildHere then local defence = defencesList[math.random(1, #defencesList)] - local unit = - Spring.CreateUnit(UnitDefNames[defence].id, posx, posy, posz, math.random(0, 3), GaiaTeamID) + local facing = math.random(0, 3) + local unit = createSnappedUnit(UnitDefNames[defence].id, posx, posy, posz, facing, GaiaTeamID) if unit then Spring.SetUnitNeutral(unit, true) Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0) diff --git a/luarules/gadgets/cmd_dev_helpers.lua b/luarules/gadgets/cmd_dev_helpers.lua index 458dc87d21e..b94d1dc42cc 100644 --- a/luarules/gadgets/cmd_dev_helpers.lua +++ b/luarules/gadgets/cmd_dev_helpers.lua @@ -2835,7 +2835,7 @@ else -- UNSYNCED end -- give units - local exlusions = { + local exclusions = { meteor = true, raptor_hive = true, nuketest = true, @@ -2845,18 +2845,18 @@ else -- UNSYNCED scavtacnukespawner = true, scavempspawner = true, } - local newExlusions = {} - for k, v in pairs(exlusions) do - newExlusions[k] = true - newExlusions[k .. "_scav"] = true + local newExclusions = {} + for k, v in pairs(exclusions) do + newExclusions[k] = true + newExclusions[k .. "_scav"] = true end - exlusions = newExlusions - newExlusions = nil + exclusions = newExclusions + newExclusions = nil local giveUnits = {} for _, ud in pairs(UnitDefs) do local give = true for _, Condition in ipairs(Accept) do - if not Condition(ud) or exlusions[ud.name] then + if not Condition(ud) or exclusions[ud.name] then give = false break end diff --git a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl index b7c3fe0aca5..a34269e81e3 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl @@ -15,11 +15,11 @@ layout (location = 0) in vec4 cubeVertex; // unit cube corner: x,z in [-0.5, 0.5], y in [0, 1] uniform vec4 radarcenter_range; // cube grid center x, emitter height, cube grid center z, effective range (elmo) -uniform vec4 gridParams; // radar cell size (elmo), coverage texels per side, cube spacing, cube cells per side +uniform vec4 gridParams; // radar cell size (elmo), coverage texels per side, cube spacing (elmo), cubes per radar cell edge uniform vec4 lookupParams; // emitter cell x, emitter cell y, radius in cells, unused uniform vec4 shapeParams; // cube width, cube height (0 = flat tile), sink below ground, lift of the top above ground uniform vec4 animParams; // time (s), seconds since the preview appeared, lod blend (0 = fine grid, 1 = double spacing), conform (1 = top follows terrain) -uniform vec4 windowParams; // first cell x, first cell z, cells per row, cell stride (1 or 2) +uniform vec4 windowParams; // first cube index x, first cube index z, cubes per row, index stride (1 or 2) uniform sampler2D heightmapTex; uniform sampler2D coverageTex; @@ -45,6 +45,8 @@ const float pulseFreq = 2.0 * PI / float(PULSE_SPACING); const float pulseSpeed = 2.0 * PI * float(PULSE_SPEED) / float(PULSE_SPACING); const float pulsePower = float(PULSE_POWER); const float pulseStrength = float(PULSE_STRENGTH); +const float edgeStrength = float(EDGE_STRENGTH); // glow of cubes at the coverage boundary (0 = off) +const float rimStrength = float(RIM_STRENGTH); // glow of the outermost ring of cubes (0 = off) const float tileMaxTilt = tan(radians(float(TILE_MAX_TILT))); // slope (rise/run) of the steepest tile tilt const float tileCliffStart = tan(radians(float(TILE_CLIFF_START))); // terrain slope where tiles start flattening const float tileCliffEnd = tan(radians(float(TILE_CLIFF_END))); // terrain slope where tiles are flat again @@ -64,6 +66,8 @@ void cullInstance() { void main() { int stride = int(windowParams.w); int rowLength = int(windowParams.z); + // absolute cube grid index: the spacing divides the radar cell size, so with center = (index + 0.5) * spacing + // every radar cell holds an NxN block of cubes centered inside it ivec2 cell = ivec2(windowParams.xy) + ivec2(gl_InstanceID % rowLength, gl_InstanceID / rowLength) * stride; // optional far LOD: cells with an odd index shrink away, the remaining ones grow to keep the visual density @@ -72,9 +76,8 @@ void main() { float lodScale = 1.0 - isFine * lodBlend; float range = radarcenter_range.w; - float halfCells = 0.5 * (gridParams.w - 1.0); - vec2 fromCenter = (vec2(cell) - halfCells) * gridParams.z; - vec2 cellXZ = radarcenter_range.xz + fromCenter; + vec2 cellXZ = (vec2(cell) + 0.5) * gridParams.z; + vec2 fromCenter = cellXZ - radarcenter_range.xz; float dist = length(fromCenter); // which radar cell is this cube in, relative to the emitter's cell @@ -116,11 +119,11 @@ void main() { // the main animation: rings travelling outward from the radar float ring = pow(0.5 + 0.5 * sin(dist * pulseFreq - time * pulseSpeed), pulsePower); - // highlight cells at the coverage boundary (an uncovered radar cell next door) and the outer rim + // highlight cells at the coverage boundary (an uncovered radar cell next door) and the outer rim (EDGE_STRENGTH, RIM_STRENGTH) float edge = coverageState.g; float rim = smoothstep(range - 1.5 * radarCell, range - 0.25 * radarCell, dist); - float glow = clamp(sweep + beam + edge * 0.5 + rim * 0.35 + ring * 0.6 * pulseStrength + bump * 0.7, 0.0, 1.5); + float glow = clamp(sweep + beam + edge * edgeStrength + rim * rimStrength + ring * 0.6 * pulseStrength + bump * 0.7, 0.0, 1.5); float height = shapeParams.y * (0.35 + 0.65 * coverage) * spawn * (1.0 + 0.5 * sweep + pulseStrength * ring + 0.6 * bump); diff --git a/luaui/Widgets/gui_buildsquare_gl4.lua b/luaui/Widgets/gui_buildsquare_gl4.lua index f954241e304..e9a4a5b6f05 100644 --- a/luaui/Widgets/gui_buildsquare_gl4.lua +++ b/luaui/Widgets/gui_buildsquare_gl4.lua @@ -61,9 +61,9 @@ local spGetUnitCommands = Spring.GetUnitCommands local spGetMyPlayerID = Spring.GetLocalPlayerID local spGetMouseState = Spring.GetMouseState local spTraceScreenRay = Spring.TraceScreenRay -local spWorldToScreenCoords = Spring.WorldToScreenCoords local spGetBuildFacing = Spring.GetBuildFacing local spTestBuildOrder = Spring.TestBuildOrder +local spGetActiveCommand = Spring.GetActiveCommand local spGetTimer = Spring.GetTimer local spDiffTimers = Spring.DiffTimers local spGetDrawFrame = Spring.GetDrawFrame @@ -93,22 +93,25 @@ local CORNER_RADIUS = 0.22 local STYLE_OPEN_YARDMAP_CELLS_AS_EXTENDED = true local EXTENDED_CELLS = 0 local COMBINE_FOUR_CELLS = true +local COMBINE_VALID_FOOTPRINT_CELLS = true -- merge same-styled cells of a placeable footprint into blocks with a single outline +local FOLLOW_EXTRACTOR_SNAP = true -- draw the preview at the spot the extractor snap widget targets instead of at the cursor local EXTENDED_ALPHA_NEAR = 0.1 local EXTENDED_ALPHA_FAR = 0.05 local FOOTPRINT_BOUNDARY_ENABLED = true local FOOTPRINT_BOUNDARY_WIDTH = 0.22 -local SHOW_INVALID_FOOTPRINT_BOUNDARY = true +local SHOW_INVALID_FOOTPRINT_BOUNDARY = false local EXTENDED_STATUS_UPDATE_INTERVAL = 0.20 local TARGET_STATUS_CHECKS_PER_GAME_FRAME = 64 local TARGET_STATUS_CELLS_PER_GAME_FRAME = 512 local MAX_STATUS_CHECK_PERIOD = 10 local SIMPLIFIED_FOOTPRINTS_ENABLED = true local SIMPLIFIED_OUTLINE_SCALE = 0.5 -local SIMPLIFIED_CORNER_RADIUS_SCALE = 0.5 +local SIMPLIFIED_CORNER_RADIUS_SCALE = 0.5 -- corner radius of simplified quads and merged blocks, relative to CORNER_RADIUS, at the reference footprint size +local SIMPLIFIED_SIZE_REFERENCE_CELLS = 6 -- footprint size (cells) at which the simplified corner radius and outline scales apply unchanged +local SIMPLIFIED_SIZE_FALLOFF = 0.55 -- corner radius and outline width vs footprint size: 0 = proportional, 1 = same for every size, in between = diminishing growth local SIMPLIFIED_BUILDING_THRESHOLD = 384 local SIMPLIFIED_CELL_THRESHOLD = 8192 local MINIMUM_SCREEN_DIAMETER = 3.0 -local MINIMUM_DETAILED_CELL_DIAMETER = 2.0 local SIMPLIFIED_MINIMAP_ENABLED = true local MAX_MINIMAP_BUILDINGS = 16384 local MAX_BATCH_CELLS = 262144 @@ -175,6 +178,15 @@ local extendedStatuses = {} local sourceCellStatuses = {} local sourceOpenYardmapFlags = {} local sourceTerrainBlockedFlags = {} +local mergeCellKeys = {} +local mergeStyleColors = {} +local mergeStyleOutlineColors = {} +local mergeStyleAlphas = {} +local mergeStyleOutlineAlphas = {} +local mergeRectData = {} +local mergeOpenRects = {} +local styleColorIds = {} +local styleColorIdCount = 0 local candidateBuildHeights = {} local candidateBuildHeightGenerations = {} local candidateBuildHeightGeneration = 0 @@ -194,7 +206,7 @@ local statusChecksEnabled = true local statusCheckTargetPhase = 0 local orderedPreviewCaches = {} local pregameStatuses = {} -local pregameStatusCount = 0 +local snapStatuses = {} local queuedBuildFootprints = {} local queuedBuildFootprintCount = 0 local queuedBuildFootprintsGameFrame = -1 @@ -417,12 +429,14 @@ local function collectPreview( zsize, extendedCells, cellScale, - simplified + simplified, + merged ) appendCollectedPreview(renderCache) if renderCache.batchNumCells ~= numCells or renderCache.batchSimplified ~= simplified + or renderCache.batchMerged ~= merged or renderCache.batchOriginX ~= originX or renderCache.batchOriginZ ~= originZ or renderCache.batchCellScale ~= cellScale @@ -436,6 +450,7 @@ local function collectPreview( renderCache.batchExtendedCells = extendedCells renderCache.batchCellScale = cellScale renderCache.batchSimplified = simplified + renderCache.batchMerged = merged end end @@ -487,8 +502,6 @@ local function getCellGeometry(xsize, zsize, extendedCells, cellScale) dataIndex = dataIndex + 1 geometryData[dataIndex] = zi * SQUARE_SIZE * cellScale dataIndex = dataIndex + 1 - geometryData[dataIndex] = cellScale > 1 and -cellScale or 0.0 - dataIndex = dataIndex + 1 geometryData[dataIndex] = footprintEdges end end @@ -529,22 +542,6 @@ local function getEffectiveExtendedCells() return effectiveExtendedCells, buildSquareGameFrame, statusCheckPeriod, drawSquareCount, simplifiedFootprintMode end -local function needsDistanceSimplification(footprint, x, z) - local minX = x - footprint.halfXsize * SQUARE_SIZE - local maxX = minX + footprint.xsize * SQUARE_SIZE - local minZ = z - footprint.halfZsize * SQUARE_SIZE - local maxZ = minZ + footprint.zsize * SQUARE_SIZE - local screenLeftX = spWorldToScreenCoords(minX, spGetGroundHeight(minX, z), z) - local screenRightX = spWorldToScreenCoords(maxX, spGetGroundHeight(maxX, z), z) - local _, screenTopY = spWorldToScreenCoords(x, spGetGroundHeight(x, minZ), minZ) - local _, screenBottomY = spWorldToScreenCoords(x, spGetGroundHeight(x, maxZ), maxZ) - if not screenLeftX or not screenRightX or not screenTopY or not screenBottomY then - return false - end - return mathAbs(screenRightX - screenLeftX) / footprint.xsize < MINIMUM_DETAILED_CELL_DIAMETER - or mathAbs(screenBottomY - screenTopY) / footprint.zsize < MINIMUM_DETAILED_CELL_DIAMETER -end - local function getPredictedCellStatus(unitDef, worldX, worldZ, buildHeight) if worldX < 0 or worldZ < 0 or worldX >= MAP_SIZE_X or worldZ >= MAP_SIZE_Z then return STATUS_BLOCKED, STATUS_OPEN @@ -697,6 +694,7 @@ local function getPreviewRenderCache(unitDefID, x, z, facing, sequenceIndex) cache.extendedStatusCount = 0 cache.colorRevision = (cache.colorRevision or 0) + 1 cache.batchNumCells = nil + cache.mergedRectCount = nil addPreviewRenderCache(cache) orderedPreviewCaches[sequenceIndex] = cache return cache @@ -712,10 +710,11 @@ local vsSrc = [[ #extension GL_ARB_shading_language_420pack: require layout (location = 0) in vec2 a_cornerPos; -layout (location = 1) in vec4 a_cellData; +layout (location = 1) in vec4 a_cellData; // x, z, quad width, quad height (world units) layout (location = 2) in vec4 a_color; layout (location = 3) in vec4 a_outlineColor; -layout (location = 4) in float a_floatOnWater; +layout (location = 4) in vec4 a_cellExtra; // floatOnWater, mode (0 cell, 1 simplified, 2 merged block), external edge mask, packed footprint data +layout (location = 5) in vec2 a_footprintSize; // footprint size in cells; merged blocks measure corner radius and outline relative to it //__ENGINEUNIFORMBUFFERDEFS__ @@ -724,7 +723,11 @@ out vec4 v_outlineColor; flat out float v_footprintEdges; flat out float v_footprintValid; flat out float v_queuedFootprintConflict; -flat out float v_simplified; +flat out float v_mode; +flat out float v_externalEdges; +flat out vec2 v_quadUnits; +flat out vec2 v_cellToUnit; +flat out float v_simplifiedSizeScale; out vec2 v_cellUV; uniform sampler2D heightmapTex; @@ -732,6 +735,8 @@ uniform float heightOffset; uniform float waterLevel; uniform float cellInset; uniform float cellSize; +uniform float simplifiedReferenceCells; +uniform float simplifiedSizeFalloff; uniform float minimumScreenDiameter; uniform int isMiniMap; uniform int rotationMiniMap; @@ -745,28 +750,60 @@ vec2 heightmapUVatWorldPos(vec2 worldpos) { } void main() { - float simplified = step(0.5, a_cellData.z); - float regularCellScale = max(1.0, -a_cellData.z); - vec2 quadSize = mix(vec2(cellSize * regularCellScale), a_cellData.zw, simplified); + float mode = a_cellExtra.y; + bool simplified = mode > 0.5 && mode < 1.5; + bool merged = mode > 1.5; + vec2 quadSize = a_cellData.zw; vec2 cellUV = a_cornerPos / cellSize; - vec2 insetCorner = cellUV * (quadSize - 2.0 * cellInset) + vec2(cellInset); + // Merged blocks are only inset on edges that are not shared with a same-styled neighbour block. + vec2 insetMin = vec2(cellInset); + vec2 insetMax = vec2(cellInset); + if (merged) { + float externalEdges = a_cellExtra.z; + insetMin.x *= mod(floor(externalEdges), 2.0); + insetMax.x *= mod(floor(externalEdges / 2.0), 2.0); + insetMin.y *= mod(floor(externalEdges / 4.0), 2.0); + insetMax.y *= mod(floor(externalEdges / 8.0), 2.0); + } + vec2 insetQuadSize = quadSize - insetMin - insetMax; + vec2 insetCorner = insetMin + cellUV * insetQuadSize; float wx = a_cellData.x + insetCorner.x; float wz = a_cellData.y + insetCorner.y; v_color = a_color; v_outlineColor = a_outlineColor; - float packedFootprintData = mix(a_cellData.w, 0.0, simplified); + float packedFootprintData = a_cellExtra.w; v_footprintEdges = mod(packedFootprintData, 16.0); v_footprintValid = step(15.5, mod(packedFootprintData, 32.0)); v_queuedFootprintConflict = step(31.5, packedFootprintData); - v_simplified = simplified; + v_mode = mode; + v_externalEdges = a_cellExtra.z; + // Merged blocks measure distances relative to the inset footprint, exactly like the simplified quad of the + // whole footprint would, so a fully placeable footprint looks identical to the simplified rendering. + vec2 quadUnits = vec2(1.0); + vec2 cellToUnit = vec2(1.0); + float simplifiedSizeScale = 1.0; + if (simplified || merged) { + vec2 footprintInsetSize = max(a_footprintSize * cellSize - 2.0 * cellInset, vec2(0.001)); + // Corner radius and outline width grow with the footprint but with diminishing returns: unchanged at the + // reference size, scaled by (reference / footprint) ^ falloff elsewhere (falloff 0 = proportional, 1 = constant). + float referenceInsetSize = max(simplifiedReferenceCells * cellSize - 2.0 * cellInset, 0.001); + simplifiedSizeScale = pow(referenceInsetSize / min(footprintInsetSize.x, footprintInsetSize.y), simplifiedSizeFalloff); + if (merged) { + quadUnits = insetQuadSize / footprintInsetSize; + cellToUnit = (cellSize - 2.0 * cellInset) / footprintInsetSize; + } + } + v_quadUnits = quadUnits; + v_cellToUnit = cellToUnit; + v_simplifiedSizeScale = simplifiedSizeScale; v_cellUV = cellUV; if (isMiniMap == 0) { vec2 uvhm = heightmapUVatWorldPos(vec2(wx, wz)); float wy = textureLod(heightmapTex, uvhm, 0.0).x; - wy = mix(wy, max(wy, waterLevel), a_floatOnWater) + heightOffset; + wy = mix(wy, max(wy, waterLevel), a_cellExtra.x) + heightOffset; vec4 clipPosition = cameraViewProj * vec4(wx, wy, wz, 1.0); - if (simplified > 0.5) { + if (simplified) { vec2 centerWorldPos = a_cellData.xy + quadSize * 0.5; vec4 centerClipPosition = cameraViewProj * vec4(centerWorldPos.x, wy, centerWorldPos.y, 1.0); vec2 centerNdcPosition = centerClipPosition.xy / centerClipPosition.w; @@ -805,23 +842,84 @@ in vec4 v_outlineColor; flat in float v_footprintEdges; flat in float v_footprintValid; flat in float v_queuedFootprintConflict; -flat in float v_simplified; +flat in float v_mode; +flat in float v_externalEdges; +flat in vec2 v_quadUnits; +flat in vec2 v_cellToUnit; +flat in float v_simplifiedSizeScale; in vec2 v_cellUV; out vec4 fragColor; uniform float simplifiedOutlineScale; uniform float simplifiedCornerRadiusScale; uniform float cornerRadius; +uniform float cellInset; +uniform float cellSize; uniform float footprintBoundaryEnabled; uniform float footprintBoundaryWidth; uniform float showInvalidFootprintBoundary; uniform vec4 invalidFootprintBoundaryColor; void main() { - float scaledCornerRadius = cornerRadius * mix(1.0, simplifiedCornerRadiusScale, v_simplified); - float outlineWidth = 0.045 * mix(1.0, simplifiedOutlineScale, v_simplified); - vec2 cornerDistance = abs(v_cellUV - vec2(0.5)) - vec2(0.5 - scaledCornerRadius); - float distanceToEdge = length(max(cornerDistance, 0.0)) - scaledCornerRadius; + bool merged = v_mode > 1.5; + // Simplified quads and merged blocks share the same corner radius and outline width (relative to the footprint). + float simplifiedMix = (v_mode > 0.5) ? 1.0 : 0.0; + float scaledCornerRadius = min( + cornerRadius * mix(1.0, simplifiedCornerRadiusScale * v_simplifiedSizeScale, simplifiedMix), + 0.5 + ); + float outlineWidth = 0.045 * mix(1.0, simplifiedOutlineScale * v_simplifiedSizeScale, simplifiedMix); + // Distances are measured in inset-quad units for cells and simplified quads (the quad is one unit) and in + // inset-footprint units for merged blocks (v_quadUnits = block size relative to the footprint), whose edges + // shared with a same-styled neighbour are ignored so adjacent blocks join seamlessly. + vec2 quadUnits = merged ? v_quadUnits : vec2(1.0); + vec2 quadPos = v_cellUV * quadUnits; + float distanceToEdge; + if (merged) { + // Shared edges are pushed far away. Use exact selects, never mix() with the large constant: in float + // precision that quantises the distances and turns the corners into staircases. + float farAway = 1.0e5; + bool leftExternal = mod(floor(v_externalEdges), 2.0) > 0.5; + bool rightExternal = mod(floor(v_externalEdges / 2.0), 2.0) > 0.5; + bool topExternal = mod(floor(v_externalEdges / 4.0), 2.0) > 0.5; + bool bottomExternal = mod(floor(v_externalEdges / 8.0), 2.0) > 0.5; + // The rounding has to fit inside the block: half its extent on an axis with both edges exposed, the full + // extent with one exposed edge, unlimited when neither edge is exposed. + float radiusLimitX = (leftExternal && rightExternal) ? quadUnits.x * 0.5 + : ((leftExternal || rightExternal) ? quadUnits.x : farAway); + float radiusLimitY = (topExternal && bottomExternal) ? quadUnits.y * 0.5 + : ((topExternal || bottomExternal) ? quadUnits.y : farAway); + float blockCornerRadius = min(scaledCornerRadius, min(radiusLimitX, radiusLimitY)); + float distanceX = min(leftExternal ? quadPos.x : farAway, rightExternal ? quadUnits.x - quadPos.x : farAway); + float distanceY = min(topExternal ? quadPos.y : farAway, bottomExternal ? quadUnits.y - quadPos.y : farAway); + vec2 cornerDistance = vec2(blockCornerRadius) - vec2(distanceX, distanceY); + distanceToEdge = length(max(cornerDistance, 0.0)) + min(max(cornerDistance.x, cornerDistance.y), 0.0) - blockCornerRadius; + // Concave corners (two shared edges meeting a diagonal block of another style): carve the cell inset around + // the corner point so that block's margin and outline continue around the corner. + float reflexCorners = floor(v_externalEdges / 16.0); + if (reflexCorners > 0.5) { + vec2 cellPos = quadPos / v_cellToUnit; + vec2 blockCells = quadUnits / v_cellToUnit; + float insetCells = cellInset / max(cellSize - 2.0 * cellInset, 0.001); + float reflexDistance = -farAway; + if (mod(reflexCorners, 2.0) > 0.5) { + reflexDistance = max(reflexDistance, insetCells - length(cellPos)); + } + if (mod(floor(reflexCorners / 2.0), 2.0) > 0.5) { + reflexDistance = max(reflexDistance, insetCells - length(cellPos - vec2(blockCells.x, 0.0))); + } + if (mod(floor(reflexCorners / 4.0), 2.0) > 0.5) { + reflexDistance = max(reflexDistance, insetCells - length(cellPos - vec2(0.0, blockCells.y))); + } + if (mod(floor(reflexCorners / 8.0), 2.0) > 0.5) { + reflexDistance = max(reflexDistance, insetCells - length(cellPos - blockCells)); + } + distanceToEdge = max(distanceToEdge, reflexDistance * v_cellToUnit.x); + } + } else { + vec2 cornerDistance = abs(v_cellUV - vec2(0.5)) - vec2(0.5 - scaledCornerRadius); + distanceToEdge = length(max(cornerDistance, 0.0)) - scaledCornerRadius; + } float antialiasWidth = fwidth(distanceToEdge); float coverage = 1.0 - smoothstep(0.0, antialiasWidth, distanceToEdge); float outline = smoothstep(-outlineWidth - antialiasWidth, -outlineWidth + antialiasWidth, distanceToEdge); @@ -835,10 +933,12 @@ void main() { float rightEdge = mod(floor(v_footprintEdges / 2.0), 2.0); float topEdge = mod(floor(v_footprintEdges / 4.0), 2.0); float bottomEdge = mod(floor(v_footprintEdges / 8.0), 2.0); - float leftOutline = leftEdge * (1.0 - smoothstep(0.0, footprintBoundaryWidth, v_cellUV.x)); - float rightOutline = rightEdge * smoothstep(1.0 - footprintBoundaryWidth, 1.0, v_cellUV.x); - float topOutline = topEdge * (1.0 - smoothstep(0.0, footprintBoundaryWidth, v_cellUV.y)); - float bottomOutline = bottomEdge * smoothstep(1.0 - footprintBoundaryWidth, 1.0, v_cellUV.y); + // The boundary band keeps its per-cell width on merged blocks. + vec2 boundaryWidth = footprintBoundaryWidth * v_cellToUnit; + float leftOutline = leftEdge * (1.0 - smoothstep(0.0, boundaryWidth.x, quadPos.x)); + float rightOutline = rightEdge * smoothstep(quadUnits.x - boundaryWidth.x, quadUnits.x, quadPos.x); + float topOutline = topEdge * (1.0 - smoothstep(0.0, boundaryWidth.y, quadPos.y)); + float bottomOutline = bottomEdge * smoothstep(quadUnits.y - boundaryWidth.y, quadUnits.y, quadPos.y); float footprintOutline = max(max(leftOutline, rightOutline), max(topOutline, bottomOutline)); color = mix(color, invalidFootprintBoundaryColor.rgb, footprintOutline); alpha = mix(alpha, invalidFootprintBoundaryColor.a, footprintOutline); @@ -889,6 +989,8 @@ local function initGL4Resources() cornerRadius = CORNER_RADIUS, simplifiedOutlineScale = SIMPLIFIED_OUTLINE_SCALE, simplifiedCornerRadiusScale = SIMPLIFIED_CORNER_RADIUS_SCALE, + simplifiedReferenceCells = SIMPLIFIED_SIZE_REFERENCE_CELLS, + simplifiedSizeFalloff = SIMPLIFIED_SIZE_FALLOFF, footprintBoundaryEnabled = FOOTPRINT_BOUNDARY_ENABLED and 1.0 or 0.0, footprintBoundaryWidth = FOOTPRINT_BOUNDARY_WIDTH, showInvalidFootprintBoundary = SHOW_INVALID_FOOTPRINT_BOUNDARY and 1.0 or 0.0, @@ -929,7 +1031,8 @@ local function initGL4Resources() { id = 1, name = "a_cellData", size = 4 }, { id = 2, name = "a_color", size = 4 }, { id = 3, name = "a_outlineColor", size = 4 }, - { id = 4, name = "a_floatOnWater", size = 1 }, + { id = 4, name = "a_cellExtra", size = 4 }, + { id = 5, name = "a_footprintSize", size = 2 }, }) batchVAO = glGetVAO() @@ -941,7 +1044,8 @@ local function initGL4Resources() { id = 1, name = "a_cellData", size = 4 }, { id = 2, name = "a_color", size = 4 }, { id = 3, name = "a_outlineColor", size = 4 }, - { id = 4, name = "a_floatOnWater", size = 1 }, + { id = 4, name = "a_cellExtra", size = 4 }, + { id = 5, name = "a_footprintSize", size = 2 }, }) minimapVAO = glGetVAO() @@ -1127,6 +1231,249 @@ local function updateExtendedStatuses( return extendedStatuses, openYardmapStatuses, openYardmapStatusesChanged end +-- Style of a cell inside the footprint (extended cells outside the footprint use a distance based alpha instead). +local function getFootprintCellStyle(status, isOpenYardmapCell, isOpenYardmapTerrainBlocked, footprintIsValid) + local color = STATUS_COLORS[status] or STATUS_COLORS[STATUS_BLOCKED] + local outlineColor = STATUS_OUTLINE_COLORS[status] or STATUS_OUTLINE_COLORS[STATUS_BLOCKED] + local isPlaceableOverObject = footprintIsValid and (status == STATUS_OCCUPIED or status == STATUS_RECLAIMABLE) + if isOpenYardmapCell and status ~= STATUS_BLOCKED then + color = VALID_FOOTPRINT_COLOR + outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR + elseif footprintIsValid and status == STATUS_OPEN then + color = VALID_FOOTPRINT_COLOR + outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR + elseif isPlaceableOverObject then + outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR + end + local alpha = isPlaceableOverObject and VALID_FOOTPRINT_COLOR[4] or color[4] + local outlineAlpha = outlineColor[4] + if OPEN_YARDMAP_ONLY_WHEN_BLOCKED and footprintIsValid and isOpenYardmapCell then + alpha = 0 + outlineAlpha = 0 + elseif isOpenYardmapCell and not isOpenYardmapTerrainBlocked then + alpha = EXTENDED_ALPHA_NEAR + outlineAlpha = outlineAlpha * alpha + end + return color, outlineColor, alpha, outlineAlpha +end + +local function getStyleColorId(color) + local id = styleColorIds[color] + if not id then + styleColorIdCount = styleColorIdCount + 1 + id = styleColorIdCount + styleColorIds[color] = id + end + return id +end + +-- Merges same-styled cells of a placeable footprint into rectangles. Every rectangle edge is either fully shared +-- with a same-styled neighbour rectangle (drawn without inset or outline so the blocks join into one shape) or +-- fully exposed. +-- Writes 14 numbers per rectangle into rectData: cell x, cell z, width, height (cells), external edge mask plus +-- 16 * concave corner mask, packed footprint data, fill rgba, outline rgba. Returns the rectangle count. +local function buildMergedFootprintRects(rectData, statuses, footprint, queuedFootprintConflict) + local xsize = footprint.xsize + local zsize = footprint.zsize + -- Hidden open yardmap cells get no rectangle, which keeps them as holes in the merged shape. + local openYardmapCells = footprint.openYardmapCells + local cellKeys = mergeCellKeys + for cellIndex = 1, xsize * zsize do + local status = statuses[cellIndex] or STATUS_BLOCKED + local isOpenYardmapCell = (openYardmapCells and openYardmapCells[cellIndex]) or false + local color, outlineColor, alpha, outlineAlpha = getFootprintCellStyle(status, isOpenYardmapCell, false, true) + if alpha <= 0 and outlineAlpha <= 0 then + cellKeys[cellIndex] = false + else + local key = getStyleColorId(color) + + getStyleColorId(outlineColor) * 64 + + math.floor(alpha * 1000 + 0.5) * 4096 + + math.floor(outlineAlpha * 1000 + 0.5) * 4194304 + cellKeys[cellIndex] = key + mergeStyleColors[key] = color + mergeStyleOutlineColors[key] = outlineColor + mergeStyleAlphas[key] = alpha + mergeStyleOutlineAlphas[key] = outlineAlpha + end + end + + local function hasSameKey(xi, zi, key) + if xi < 0 or zi < 0 or xi >= xsize or zi >= zsize then + return false + end + return cellKeys[zi * xsize + xi + 1] == key + end + + -- Pass 1: horizontal runs whose cells agree on whether the cell above / below is same-styled, so the top and + -- bottom edges of a run are uniform. Pass 2 (interleaved): extend the run from the previous row when x, width, + -- key and the left / right neighbour status match, keeping every edge of the rectangle uniform. + -- Scratch layout per rectangle (10 numbers): x, z, width, height, key, leftShared, rightShared, topShared, + -- bottomShared, last row. + local scratch = mergeRectData + local openRects = mergeOpenRects + for openX in pairs(openRects) do + openRects[openX] = nil + end + local rectCount = 0 + for zi = 0, zsize - 1 do + local xi = 0 + while xi < xsize do + local key = cellKeys[zi * xsize + xi + 1] + if not key then + xi = xi + 1 + else + local topShared = hasSameKey(xi, zi - 1, key) + local bottomShared = hasSameKey(xi, zi + 1, key) + local width = 1 + while + xi + width < xsize + and cellKeys[zi * xsize + xi + width + 1] == key + and hasSameKey(xi + width, zi - 1, key) == topShared + and hasSameKey(xi + width, zi + 1, key) == bottomShared + do + width = width + 1 + end + local leftShared = hasSameKey(xi - 1, zi, key) + local rightShared = hasSameKey(xi + width, zi, key) + local rectIndex = openRects[xi] + local rectBase = rectIndex and (rectIndex - 1) * 10 + if + rectBase + and scratch[rectBase + 10] == zi - 1 + and scratch[rectBase + 3] == width + and scratch[rectBase + 5] == key + and scratch[rectBase + 6] == leftShared + and scratch[rectBase + 7] == rightShared + then + scratch[rectBase + 4] = scratch[rectBase + 4] + 1 + scratch[rectBase + 9] = bottomShared + scratch[rectBase + 10] = zi + else + rectCount = rectCount + 1 + rectBase = (rectCount - 1) * 10 + scratch[rectBase + 1] = xi + scratch[rectBase + 2] = zi + scratch[rectBase + 3] = width + scratch[rectBase + 4] = 1 + scratch[rectBase + 5] = key + scratch[rectBase + 6] = leftShared + scratch[rectBase + 7] = rightShared + scratch[rectBase + 8] = topShared + scratch[rectBase + 9] = bottomShared + scratch[rectBase + 10] = zi + openRects[xi] = rectCount + end + xi = xi + width + end + end + end + + local packedFlags = 16 + (queuedFootprintConflict and 32 or 0) + local dataIndex = 0 + for rectIndex = 0, rectCount - 1 do + local rectBase = rectIndex * 10 + local rectX = scratch[rectBase + 1] + local rectZ = scratch[rectBase + 2] + local rectWidth = scratch[rectBase + 3] + local rectHeight = scratch[rectBase + 4] + local key = scratch[rectBase + 5] + local leftShared = scratch[rectBase + 6] + local rightShared = scratch[rectBase + 7] + local topShared = scratch[rectBase + 8] + local bottomShared = scratch[rectBase + 9] + local externalEdges = (leftShared and 0 or 1) + + (rightShared and 0 or 2) + + (topShared and 0 or 4) + + (bottomShared and 0 or 8) + -- Concave corners: two shared edges meeting a diagonal cell of another style. The shader wraps the margin + -- and outline of the neighbouring block around such a corner point so hole outlines stay continuous. + local reflexCorners = (topShared and leftShared and not hasSameKey(rectX - 1, rectZ - 1, key) and 1 or 0) + + (topShared and rightShared and not hasSameKey(rectX + rectWidth, rectZ - 1, key) and 2 or 0) + + (bottomShared and leftShared and not hasSameKey(rectX - 1, rectZ + rectHeight, key) and 4 or 0) + + (bottomShared and rightShared and not hasSameKey(rectX + rectWidth, rectZ + rectHeight, key) and 8 or 0) + local footprintEdges = (rectX == 0 and 1 or 0) + + (rectX + rectWidth == xsize and 2 or 0) + + (rectZ == 0 and 4 or 0) + + (rectZ + rectHeight == zsize and 8 or 0) + local color = mergeStyleColors[key] + local outlineColor = mergeStyleOutlineColors[key] + rectData[dataIndex + 1] = rectX + rectData[dataIndex + 2] = rectZ + rectData[dataIndex + 3] = rectWidth + rectData[dataIndex + 4] = rectHeight + rectData[dataIndex + 5] = externalEdges + reflexCorners * 16 + rectData[dataIndex + 6] = footprintEdges + packedFlags + rectData[dataIndex + 7] = color[1] + rectData[dataIndex + 8] = color[2] + rectData[dataIndex + 9] = color[3] + rectData[dataIndex + 10] = mergeStyleAlphas[key] + rectData[dataIndex + 11] = outlineColor[1] + rectData[dataIndex + 12] = outlineColor[2] + rectData[dataIndex + 13] = outlineColor[3] + rectData[dataIndex + 14] = mergeStyleOutlineAlphas[key] + dataIndex = dataIndex + 14 + end + return rectCount +end + +-- Per-cell statuses for a placement the engine did not evaluate for us (pregame, extractor snap target). +local function fillPredictedStatuses(statusList, unitDef, x, buildHeight, z, footprint, placementValid) + local statusIndex = 0 + for zi = 0, footprint.zsize - 1 do + for xi = 0, footprint.xsize - 1 do + statusIndex = statusIndex + 1 + local status = STATUS_BLOCKED + if placementValid then + status = getPredictedCellStatus( + unitDef, + x + (xi - footprint.halfXsize) * SQUARE_SIZE, + z + (zi - footprint.halfZsize) * SQUARE_SIZE, + buildHeight + ) + -- The engine accepted the order, so a cell the prediction calls blocked is really placeable + -- (e.g. an extractor upgrade over stackable / build-only yardmap squares). + if status == STATUS_BLOCKED then + status = STATUS_OPEN + end + end + statusList[statusIndex] = status + end + end + for index = statusIndex + 1, #statusList do + statusList[index] = nil + end +end + +-- When the extractor snap widget targets a resource spot for the active build command, the preview belongs at +-- that spot rather than at the cursor. Returns the snapped x, z and predicted statuses, or nil. +local function getExtractorSnapPlacement(unitDefID, facing, footprint) + if not FOLLOW_EXTRACTOR_SNAP then + return nil + end + local extractorSnap = WG.ExtractorSnap + local snapPosition = extractorSnap and extractorSnap.position + if not snapPosition then + return nil + end + if spGetGameFrame() > 0 then + local _, activeCmdID = spGetActiveCommand() + if not activeCmdID or -activeCmdID ~= unitDefID then + return nil + end + end + local x, buildHeight, z = spPos2BuildPos(unitDefID, snapPosition.x, snapPosition.y, snapPosition.z, facing) + if not x or not buildHeight or not z then + return nil + end + local unitDef = UnitDefs[unitDefID] + if not unitDef then + return nil + end + local placementValid = spTestBuildOrder(unitDefID, x, buildHeight, z, facing) ~= 0 + fillPredictedStatuses(snapStatuses, unitDef, x, buildHeight, z, footprint, placementValid) + return x, z, snapStatuses +end + function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) --Spring.Echo("DrawBuildSquare called with unitDefID:", unitDefID, "x:", x, "z:", z, "facing:", facing, "statuses length:", #statuses) local extendedCells, gameFrame, footprintStatusCheckPeriod, sequenceIndex, simplified = getEffectiveExtendedCells() @@ -1134,6 +1481,10 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) if not footprint then return end + local snapX, snapZ, snapCellStatuses = getExtractorSnapPlacement(unitDefID, facing, footprint) + if snapX then + x, z, statuses = snapX, snapZ, snapCellStatuses + end local footprintCellCount = footprint.cellCount local footprintIsValid = true for cellIdx = 1, footprintCellCount do @@ -1159,9 +1510,8 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) return end -- Avoid a one-frame VBO overflow before the next frame enables global simplification. - simplified = simplified - or drawSquareCellCount + sourceCellCount > SIMPLIFIED_CELL_THRESHOLD - or needsDistanceSimplification(footprint, placementX, placementZ) + simplified = simplified or drawSquareCellCount + sourceCellCount > SIMPLIFIED_CELL_THRESHOLD + local merged = COMBINE_VALID_FOOTPRINT_CELLS and not simplified and footprintIsValid and extendedCells == 0 local renderCache = orderedPreviewCaches[sequenceIndex] if extendedCells == 0 @@ -1173,6 +1523,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) and renderCache.colorValid and renderCache.extendedStatusCount == 0 and renderCache.simplifiedMode == simplified + and renderCache.mergedMode == merged and renderCache.queuedFootprintConflict == queuedFootprintConflict then local previewWasDrawnLastFrame = renderCache.lastDrawFrame == extendedCellsDrawFrame - 1 @@ -1191,11 +1542,16 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) end end - local cellScale = COMBINE_FOUR_CELLS and xsize % 2 == 0 and zsize % 2 == 0 and extendedCells % 2 == 0 and 2 or 1 + local cellScale = not merged + and COMBINE_FOUR_CELLS + and xsize % 2 == 0 + and zsize % 2 == 0 + and extendedCells % 2 == 0 + and 2 + or 1 local renderXSize = totalXSize / cellScale local renderZSize = totalZSize / cellScale local renderCellCount = renderXSize * renderZSize - local renderInstanceCount = simplified and 1 or renderCellCount drawSquareCellCount = drawSquareCellCount + sourceCellCount local sx = centerGridX - footprint.halfXsize local sz = centerGridZ - footprint.halfZsize @@ -1216,18 +1572,20 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) and renderCache.colorValid and renderCache.extendedStatusCount == 0 and renderCache.simplifiedMode == simplified + and renderCache.mergedMode == merged and not statusCheckDue then collectPreview( renderCache, sx * SQUARE_SIZE, sz * SQUARE_SIZE, - renderInstanceCount, + simplified and 1 or (merged and renderCache.mergedRectCount) or renderCellCount, xsize, zsize, extendedCells, cellScale, - simplified + simplified, + merged ) return end @@ -1252,6 +1610,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) local extendedStatusCount = sourceCellCount - footprintCellCount local needsColorUpload = not renderCache.colorValid or renderCache.simplifiedMode ~= simplified + or renderCache.mergedMode ~= merged or openYardmapStatusesChanged or renderCache.queuedFootprintConflict ~= queuedFootprintConflict @@ -1282,6 +1641,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) local colorData = renderCache.colorData renderCache.colorValid = true renderCache.simplifiedMode = simplified + renderCache.mergedMode = merged renderCache.sourceCellCount = sourceCellCount renderCache.statusCheckGameFrame = gameFrame renderCache.extendedStatusCount = extendedStatusCount @@ -1318,6 +1678,30 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) zsize, extendedCells, cellScale, + true, + false + ) + return + end + if merged then + local rectCount = buildMergedFootprintRects(colorData, statuses, footprint, queuedFootprintConflict) + for index = rectCount * 14 + 1, renderCache.colorDataLength or 0 do + colorData[index] = nil + end + renderCache.colorDataLength = rectCount * 14 + renderCache.mergedRectCount = rectCount + renderCache.colorRevision = (renderCache.colorRevision or 0) + 1 + tracy.ZoneEnd() + collectPreview( + renderCache, + sx * SQUARE_SIZE, + sz * SQUARE_SIZE, + rectCount, + xsize, + zsize, + extendedCells, + cellScale, + false, true ) return @@ -1394,41 +1778,17 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) local xi = renderXi - renderExtendedCells local zi = renderZi - renderExtendedCells local isFootprintCell = xi >= 0 and xi < renderFootprintXSize and zi >= 0 and zi < renderFootprintZSize - local color = STATUS_COLORS[status] or STATUS_COLORS[STATUS_BLOCKED] - local outlineColor = STATUS_OUTLINE_COLORS[status] or STATUS_OUTLINE_COLORS[STATUS_BLOCKED] - if isOpenYardmapCell and status ~= STATUS_BLOCKED then - color = VALID_FOOTPRINT_COLOR - outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR - elseif isFootprintCell and footprintIsValid and status == STATUS_OPEN then - color = VALID_FOOTPRINT_COLOR - outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR - elseif - isFootprintCell - and footprintIsValid - and (status == STATUS_OCCUPIED or status == STATUS_RECLAIMABLE) - then - outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR - elseif not isFootprintCell and status ~= STATUS_BLOCKED then - color = VALID_FOOTPRINT_COLOR - outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR - end - - local alpha = color[4] - local outlineAlpha = outlineColor[4] - if - isFootprintCell - and footprintIsValid - and (status == STATUS_OCCUPIED or status == STATUS_RECLAIMABLE) - then - alpha = VALID_FOOTPRINT_COLOR[4] - end - if OPEN_YARDMAP_ONLY_WHEN_BLOCKED and footprintIsValid and isOpenYardmapCell then - alpha = 0 - outlineAlpha = 0 - elseif isOpenYardmapCell and not isOpenYardmapTerrainBlocked then - alpha = EXTENDED_ALPHA_NEAR - outlineAlpha = outlineAlpha * alpha - elseif not isFootprintCell then + local color, outlineColor, alpha, outlineAlpha + if isFootprintCell then + color, outlineColor, alpha, outlineAlpha = + getFootprintCellStyle(status, isOpenYardmapCell, isOpenYardmapTerrainBlocked, footprintIsValid) + else + color = STATUS_COLORS[status] or STATUS_COLORS[STATUS_BLOCKED] + outlineColor = STATUS_OUTLINE_COLORS[status] or STATUS_OUTLINE_COLORS[STATUS_BLOCKED] + if status ~= STATUS_BLOCKED then + color = VALID_FOOTPRINT_COLOR + outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR + end local dx = xi < 0 and -xi or (xi - renderFootprintXSize + 1) local dz = zi < 0 and -zi or (zi - renderFootprintZSize + 1) local distance = math.max(dx, dz) @@ -1438,7 +1798,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) local alphaFar = alphaMidpoint + (EXTENDED_ALPHA_FAR - alphaMidpoint) * alphaRangeScale local t = (distance - 1) / math.max(1, renderExtendedCells - 1) alpha = alphaNear + (alphaFar - alphaNear) * t - outlineAlpha = outlineAlpha * alpha + outlineAlpha = outlineColor[4] * alpha end colorDataIndex = colorDataIndex + 1 @@ -1472,12 +1832,13 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses) renderCache, sx * SQUARE_SIZE, sz * SQUARE_SIZE, - renderInstanceCount, + simplified and 1 or (merged and renderCache.mergedRectCount) or renderCellCount, xsize, zsize, extendedCells, cellScale, - simplified + simplified, + merged ) end @@ -1521,26 +1882,7 @@ local function collectPregameBuildSquare() end local placementValid = spTestBuildOrder(unitDefID, x, buildHeight, z, facing) ~= 0 - local statusIndex = 0 - for zi = 0, footprint.zsize - 1 do - for xi = 0, footprint.xsize - 1 do - statusIndex = statusIndex + 1 - if placementValid then - pregameStatuses[statusIndex] = getPredictedCellStatus( - unitDef, - x + (xi - footprint.halfXsize) * SQUARE_SIZE, - z + (zi - footprint.halfZsize) * SQUARE_SIZE, - buildHeight - ) - else - pregameStatuses[statusIndex] = STATUS_BLOCKED - end - end - end - for index = statusIndex + 1, pregameStatusCount do - pregameStatuses[index] = nil - end - pregameStatusCount = statusIndex + fillPredictedStatuses(pregameStatuses, unitDef, x, buildHeight, z, footprint, placementValid) widget:DrawBuildSquare(unitDefID, x, z, facing, pregameStatuses) end @@ -1585,11 +1927,26 @@ local function rebuildBatchBuffer() minimapInstanceData[minimapDataIndex] = minimapOutlineColor[4] minimapDataIndex = minimapDataIndex + 1 minimapInstanceData[minimapDataIndex] = renderCache.floatOnWater + minimapDataIndex = minimapDataIndex + 1 + minimapInstanceData[minimapDataIndex] = 1 -- simplified quad + minimapDataIndex = minimapDataIndex + 1 + minimapInstanceData[minimapDataIndex] = 0 + minimapDataIndex = minimapDataIndex + 1 + minimapInstanceData[minimapDataIndex] = 0 + minimapDataIndex = minimapDataIndex + 1 + minimapInstanceData[minimapDataIndex] = renderCache.batchXsize + minimapDataIndex = minimapDataIndex + 1 + minimapInstanceData[minimapDataIndex] = renderCache.batchZsize minimapCount = minimapCount + 1 end if not capacityReached and instanceCount + renderCache.batchNumCells <= MAX_BATCH_CELLS then local colorData = renderCache.colorData + local originX = renderCache.batchOriginX + local originZ = renderCache.batchOriginZ + local floatOnWater = renderCache.floatOnWater + local footprintXsize = renderCache.batchXsize + local footprintZsize = renderCache.batchZsize if renderCache.batchSimplified then dataIndex = dataIndex + 1 batchInstanceData[dataIndex] = renderCache.batchOriginX @@ -1616,30 +1973,67 @@ local function rebuildBatchBuffer() dataIndex = dataIndex + 1 batchInstanceData[dataIndex] = colorData[8] dataIndex = dataIndex + 1 - batchInstanceData[dataIndex] = renderCache.floatOnWater + batchInstanceData[dataIndex] = floatOnWater + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = 1 -- simplified quad + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = 0 + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = 0 + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = footprintXsize + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = footprintZsize + elseif renderCache.batchMerged then + for rectIndex = 0, renderCache.batchNumCells - 1 do + local rectDataIndex = rectIndex * 14 + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = originX + colorData[rectDataIndex + 1] * SQUARE_SIZE + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = originZ + colorData[rectDataIndex + 2] * SQUARE_SIZE + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = colorData[rectDataIndex + 3] * SQUARE_SIZE + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = colorData[rectDataIndex + 4] * SQUARE_SIZE + for colorIndex = 7, 14 do + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = colorData[rectDataIndex + colorIndex] + end + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = floatOnWater + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = 2 -- merged block + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = colorData[rectDataIndex + 5] + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = colorData[rectDataIndex + 6] + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = footprintXsize + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = footprintZsize + end else local cellScale = renderCache.batchCellScale or 1 + local quadSize = SQUARE_SIZE * cellScale local geometryData = getCellGeometry( renderCache.batchXsize / cellScale, renderCache.batchZsize / cellScale, renderCache.batchExtendedCells / cellScale, cellScale ) - local footprintValidityFlag = renderCache.footprintIsValid and 16 or 0 - local queuedFootprintConflictFlag = renderCache.queuedFootprintConflict and 32 or 0 + local packedFlags = (renderCache.footprintIsValid and 16 or 0) + + (renderCache.queuedFootprintConflict and 32 or 0) for cellIndex = 0, renderCache.batchNumCells - 1 do - local geometryIndex = cellIndex * 4 + local geometryIndex = cellIndex * 3 local colorIndex = cellIndex * 8 dataIndex = dataIndex + 1 - batchInstanceData[dataIndex] = geometryData[geometryIndex + 1] + renderCache.batchOriginX + batchInstanceData[dataIndex] = geometryData[geometryIndex + 1] + originX dataIndex = dataIndex + 1 - batchInstanceData[dataIndex] = geometryData[geometryIndex + 2] + renderCache.batchOriginZ + batchInstanceData[dataIndex] = geometryData[geometryIndex + 2] + originZ dataIndex = dataIndex + 1 - batchInstanceData[dataIndex] = geometryData[geometryIndex + 3] + batchInstanceData[dataIndex] = quadSize dataIndex = dataIndex + 1 - batchInstanceData[dataIndex] = geometryData[geometryIndex + 4] - + footprintValidityFlag - + queuedFootprintConflictFlag + batchInstanceData[dataIndex] = quadSize dataIndex = dataIndex + 1 batchInstanceData[dataIndex] = colorData[colorIndex + 1] dataIndex = dataIndex + 1 @@ -1657,7 +2051,17 @@ local function rebuildBatchBuffer() dataIndex = dataIndex + 1 batchInstanceData[dataIndex] = colorData[colorIndex + 8] dataIndex = dataIndex + 1 - batchInstanceData[dataIndex] = renderCache.floatOnWater + batchInstanceData[dataIndex] = floatOnWater + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = 0 -- regular cell + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = 0 + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = geometryData[geometryIndex + 3] + packedFlags + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = footprintXsize + dataIndex = dataIndex + 1 + batchInstanceData[dataIndex] = footprintZsize end end instanceCount = instanceCount + renderCache.batchNumCells diff --git a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua index ce4f54eec6e..bc99f5b4b20 100644 --- a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua +++ b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua @@ -20,7 +20,8 @@ end -- engine's radar LOS (LosMap.cpp: midpoint-circle disk, rays, CastLos angle test) per radar cell. -- 2. Smoothing pass (every frame, a few thousand texels): a ping-ponged state texture eases towards -- the coverage, so cubes rise/sink smoothly instead of popping while dragging. --- 3. Cube pass: one instanced draw of a unit cube per 16 elmo grid cell. The vertex shader looks up +-- 3. Cube pass: one instanced draw of a unit cube per cube of the NxN block inside each radar cell +-- (CUBES_PER_CELL by radarMipLevel, cube size as a fraction of the spacing). The vertex shader looks up -- the radar cell the cube is in, samples the heightmap and animates the cube; the fragment shader -- does per-face shading and zoom-independent edge lines. Only cells under the screen's ground -- footprint are drawn; flat shapes draw a single quad per cell. Optionally (LOD_MAX_INSTANCES) the @@ -28,15 +29,18 @@ end ------------------------------------------------------------------------------------------------ -- Tunables -local CUBE_SPACING = 16 -- elmos between cube centers; must divide the radar cell size -local CUBE_WIDTH = 5.5 -- elmos +-- Cubes are laid out as an NxN block centered inside every radar cell (cell size = 8 << radarMipLevel elmo); +-- N by radarMipLevel, so the look stays consistent when the game's radar resolution changes. +local CUBES_PER_CELL = { [1] = 2, [2] = 3, [3] = 5, [4] = 8 } +local CUBE_FILL = 0.28 -- cube width as a fraction of the cube spacing (spacing = radar cell size / N) local CUBE_SINK = 2 -- elmos the cube base is pushed below ground, so cubes never float on slopes local CUBE_SHAPE = "tile" -- default shape, see CUBE_SHAPES; switch at runtime with WG.radarPreview.setShape(name) local CUBE_SHAPES = { - -- height at full coverage (elmo), lift of the top face above ground (elmo), conform = top face tilts with the terrain - cube = { height = 6, lift = 0, conform = 0 }, - slab = { height = 3, lift = 0, conform = 0 }, - tile = { height = 0.5, lift = 0.5, conform = 0 }, + -- height at full coverage as a multiple of the cube width, lift of the top face above ground (elmo), + -- conform = top face tilts with the terrain + cube = { height = 2.0, lift = 0, conform = 0 }, + slab = { height = 1.0, lift = 0, conform = 0 }, + tile = { height = 0.5, lift = 0, conform = 0 }, flat = { height = 0, lift = 1.5, conform = 1 }, -- flat square } local LIFT_PER_DISTANCE = 0.001 -- extra lift per elmo of camera distance, keeps flat shapes above the terrain LOD mesh @@ -45,7 +49,7 @@ local COVERAGE_REFRESH_SECONDS = 1.0 -- periodic heightmap/coverage rebuild so t local SMOOTH_RATE = 14 -- 1/s, how fast the cubes follow coverage changes (higher = snappier) local LOD_MAX_INSTANCES = 0 -- > 0: thin the grid to double spacing once more cubes than this are on screen (cubes visibly fill in/out with zoom; try 50000 on integrated graphics) local FOOTPRINT_MARGIN = 48 -- elmos of slack around the screen's ground footprint -local MAX_RADIUS_CELLS = 160 -- sanity limit of the radar radius in cells (160 * 64 = 10240 elmo) +local MAX_RADIUS_CELLS = 256 -- sanity limit of the radar radius in cells (coverage texture and ray table size) local RAY_SSBO_BINDING = 5 -- shader storage binding of the per-radius ray table (4, 6, 7 are used elsewhere in BAR) -- With deferred map/model rendering the cubes are occluded via the g-buffer depths (terrain and units) @@ -61,19 +65,21 @@ local shaderConfig = { SWEEP_TRAIL = 30.0, -- degrees: the trail fades out this far behind the sweep's leading edge SWEEP_BEAM = 9.0, -- degrees: width of the bright leading edge SWEEP_STRENGTH = 0.55, -- how much the sweep brightens/raises cubes - SPAWN_SPEED = 1.6, -- radar ranges per second the spawn ripple travels outward - SPAWN_BUMP = 0.3, -- width of the overshoot behind the ripple front, in radar ranges + SPAWN_SPEED = 2.5, -- radar ranges per second the spawn ripple travels outward + SPAWN_BUMP = 0.25, -- width of the overshoot behind the ripple front, in radar ranges PULSE_SPACING = 180.0, -- elmos between the outward travelling wave rings PULSE_SPEED = 90.0, -- elmos per second the rings travel - PULSE_POWER = 3.0, -- higher = narrower rings - PULSE_STRENGTH = 0.75, -- how much the rings raise/brighten cubes + PULSE_POWER = 4.5, -- higher = narrower rings + PULSE_STRENGTH = 1.0, -- how much the rings raise/brighten cubes + EDGE_STRENGTH = 0.15, -- how much cubes at the coverage boundary (next to an uncovered radar cell) brighten; 0 disables + RIM_STRENGTH = 0.25, -- how much the outermost ring of cubes brightens; 0 disables TILE_MAX_TILT = 20.0, -- degrees: flat tiles follow the terrain slope up to this angle TILE_CLIFF_START = 35.0, -- degrees: terrain steeper than this starts flattening the tiles again TILE_CLIFF_END = 55.0, -- degrees: terrain steeper than this gets flat tiles (cliffs) BASE_COLOR = "vec3(0.22, 0.85, 0.50)", HIGHLIGHT_COLOR = "vec3(0.65, 1.00, 0.80)", - BASE_ALPHA = 0.3, - LINE_ALPHA = 0.3, -- opacity of the cube edge lines + BASE_ALPHA = 0.5, + LINE_ALPHA = 0.5, -- opacity of the cube edge lines } -- Engine radar model (rts/Sim/Misc/LosHandler.cpp, LosMap.cpp) @@ -89,6 +95,11 @@ local SQUARE_SIZE = 8 local RADAR_CELL = SQUARE_SIZE * 2 ^ RADAR_MIP_LEVEL -- elmos per radar cell local HEIGHT_BUCKET = 2 ^ (RADAR_MIP_LEVEL + 2) -- emitter heights are quantized to buckets of this size +-- derived cube grid: N cubes per radar cell edge (fallback: about one cube per 13 elmo) +local CUBES_PER_CELL_EDGE = CUBES_PER_CELL[RADAR_MIP_LEVEL] or math.max(1, math.floor(RADAR_CELL / 13 + 0.5)) +local CUBE_SPACING = RADAR_CELL / CUBES_PER_CELL_EDGE -- elmos between cube centers +local CUBE_WIDTH = CUBE_FILL * CUBE_SPACING -- elmos + -- Localized functions for performance local mathFloor = math.floor local mathCeil = math.ceil @@ -129,8 +140,12 @@ do local dims = Spring.GetUnitDefDimensions(unitDefID) -- ILosType::GetRadius: (radarDistance / SQUARE_SIZE) >> radarMipLevel, integer arithmetic local radiusCells = mathFloor(mathFloor(unitDef.radarDistance / SQUARE_SIZE) / 2 ^ RADAR_MIP_LEVEL) + if radiusCells > MAX_RADIUS_CELLS then + spEcho("Sensor Ranges Radar Preview: " .. unitDef.name .. " radar radius clamped to " .. MAX_RADIUS_CELLS .. " cells") + radiusCells = MAX_RADIUS_CELLS + end radarDefs[-unitDefID] = { - radiusCells = mathMin(radiusCells, MAX_RADIUS_CELLS), + radiusCells = radiusCells, emitHeight = unitDef.radarEmitHeight or 0, midY = (dims and dims.midy) or 0, -- unit->midPos.y - unit->pos.y } @@ -215,7 +230,7 @@ local cubeShaderCache = { }, uniformFloat = { radarcenter_range = { 0, 0, 0, 2000 }, - gridParams = { RADAR_CELL, 1, CUBE_SPACING, 1 }, + gridParams = { RADAR_CELL, 1, CUBE_SPACING, CUBES_PER_CELL_EDGE }, lookupParams = { 0, 0, 1, 0 }, shapeParams = { CUBE_WIDTH, 6, CUBE_SINK, 0 }, animParams = { 0, 0, 0, 0 }, @@ -422,7 +437,6 @@ local function makeSet(radiusCells) local set = { radius = radiusCells, N = coverageCells, -- coverage texels per side - M = 2 * mathCeil((radiusCells + 2) * RADAR_CELL / CUBE_SPACING) + 1, -- cube cells per side target = makeDataTexture(coverageCells, coverageCells, GL_R16F, GL.NEAREST), state = { -- R = smoothed coverage, G = smoothed boundary factor makeDataTexture(coverageCells, coverageCells, GL_RG16F, stateFilter), @@ -616,21 +630,22 @@ local function getScreenFootprint(camX, camY, camZ) return minX, maxX, minZ, maxZ end --- Which cube cells to draw this frame: the screen footprint clipped to the grid. With LOD_MAX_INSTANCES --- the grid blends to double spacing once more cubes than that would be on screen (far zoom). +-- Which cubes to draw this frame: the radar disc's cubes (absolute cube grid indices, CUBES_PER_CELL_EDGE +-- per radar cell) clipped to the screen footprint. With LOD_MAX_INSTANCES the grid blends to double +-- spacing once more cubes than that would be on screen (far zoom). -- Returns x0, z0, cellsX, cellsZ, stride, lodBlend. -local function getCubeWindow(set, cx, cz, camX, camY, camZ) - local M = set.M - local half = (M - 1) * 0.5 - - local x0, x1, z0, z1 = 0, M - 1, 0, M - 1 +local function getCubeWindow(set, bx, bz, camX, camY, camZ) + local n = CUBES_PER_CELL_EDGE + local radius = set.radius + local x0, x1 = (bx - radius) * n, (bx + radius + 1) * n - 1 + local z0, z1 = (bz - radius) * n, (bz + radius + 1) * n - 1 local minX, maxX, minZ, maxZ = getScreenFootprint(camX, camY, camZ) if minX then local margin = mathCeil(FOOTPRINT_MARGIN / CUBE_SPACING) - x0 = mathMax(0, mathFloor((minX - cx) / CUBE_SPACING + half) - margin) - x1 = mathMin(M - 1, mathCeil((maxX - cx) / CUBE_SPACING + half) + margin) - z0 = mathMax(0, mathFloor((minZ - cz) / CUBE_SPACING + half) - margin) - z1 = mathMin(M - 1, mathCeil((maxZ - cz) / CUBE_SPACING + half) + margin) + x0 = mathMax(x0, mathFloor(minX / CUBE_SPACING) - margin) + x1 = mathMin(x1, mathFloor(maxX / CUBE_SPACING) + margin) + z0 = mathMax(z0, mathFloor(minZ / CUBE_SPACING) - margin) + z1 = mathMin(z1, mathFloor(maxZ / CUBE_SPACING) + margin) end if x1 < x0 or z1 < z0 then return 0, 0, 0, 0, 1, 0 @@ -710,8 +725,7 @@ function widget:DrawWorld() local radius = def.radiusCells local range = radius * RADAR_CELL - local cx = mathFloor((mousepos[1] + CUBE_SPACING * 0.5) / CUBE_SPACING) * CUBE_SPACING - local cz = mathFloor((mousepos[3] + CUBE_SPACING * 0.5) / CUBE_SPACING) * CUBE_SPACING + local cx, cz = mousepos[1], mousepos[3] -- center of the animations; the cube grid itself is world-fixed -- frame bookkeeping: a gap in draw frames or a different radar means the preview just (re)appeared local now = osClock() @@ -769,7 +783,7 @@ function widget:DrawWorld() local camX, camY, camZ = spGetCameraPosition() local dx, dy, dz = camX - cx, camY - midY, camZ - cz local camDist = mathSqrt(dx * dx + dy * dy + dz * dz) - local x0, z0, cellsX, cellsZ, stride, lodBlend = getCubeWindow(set, cx, cz, camX, camY, camZ) + local x0, z0, cellsX, cellsZ, stride, lodBlend = getCubeWindow(set, bx, bz, camX, camY, camZ) if cellsX > 0 and cellsZ > 0 then gl.Texture(0, "$heightmap") gl.Texture(1, nextTex) @@ -786,9 +800,9 @@ function widget:DrawWorld() gl.Culling(GL.BACK) cubeShader:Activate() cubeShader:SetUniform("radarcenter_range", cx, losHeight, cz, range) - cubeShader:SetUniform("gridParams", RADAR_CELL, set.N, CUBE_SPACING, set.M) + cubeShader:SetUniform("gridParams", RADAR_CELL, set.N, CUBE_SPACING, CUBES_PER_CELL_EDGE) cubeShader:SetUniform("lookupParams", bx, bz, radius, 0) - cubeShader:SetUniform("shapeParams", CUBE_WIDTH, shape.height, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE) + cubeShader:SetUniform("shapeParams", CUBE_WIDTH, shape.height * CUBE_WIDTH, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE) cubeShader:SetUniform("animParams", now, now - spawnStart, lodBlend, shape.conform) cubeShader:SetUniform("windowParams", x0, z0, cellsX, stride) if shape.height > 0 then diff --git a/modules/unit_idle_states.lua b/modules/unit_idle_states.lua new file mode 100644 index 00000000000..263506e4072 --- /dev/null +++ b/modules/unit_idle_states.lua @@ -0,0 +1,69 @@ +-- The engine misses a very large portion of its one side of the idle responsibility: +-- A factory's build queue empties through CFactoryCAI::ExecuteStop, which is just a pop; +-- multi-command removals only "finish" the first command removed, so tend not to report; +-- units that are built or spawned are not marked as idle, though they have no commands. +-- Our code also considers newly-created units as neither idle nor busy, at the moment. + +-- In addition, units may have "idle tasks", as opposed to "busy tasks". These are not +-- issued via player input but from code. Code that issues idle tasks can detect idleness +-- via an empty command queue rather than by isIdle to avoid issuing multiple idle tasks. + +local CMD_REPAIR = CMD.REPAIR +local CMD_MOVE = CMD.MOVE +local OPT_INTERNAL = CMD.OPT_INTERNAL -- Imperfect but acceptable marker for idle tasks. + +-- Idle tasks are a pure accident of the many ways units receive commands via the engine. +-- We identify them by a command + params + internal triple. They have no simple summary. +local IDLE_TASK_PARAMS = { + [CMD_REPAIR] = 1, -- a targetID + [CMD_MOVE] = 3, -- a position +} +-- +-- Post-resurrection repair is not currently idle, by the same engine property that makes +-- most command-tracking impossible, which actually helps us here. It counts, that's a W. +-- Autotargeting is not currently idle, either, though that seems subject to change. + +local bit_and = math.bit_and + +local spGetFactoryCommandCount = Spring.GetFactoryCommandCount +local spGetUnitCommandCount = Spring.GetUnitCommandCount +local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand +local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt + +local function inIdleTaskAtIndex(unitID, index) + local cmdID, cmdOptions, _, _, secondParam, _, fourthParam = spGetUnitCurrentCommand(unitID, index) + local params = cmdID and IDLE_TASK_PARAMS[cmdID] + if not params or bit_and(cmdOptions, OPT_INTERNAL) == 0 then + return false + end + -- This is a fast parameter count but is not very future-proof. + return (params == 1 and secondParam == nil) or (params == 3 and fourthParam == nil) +end + +---Whether everything in the unit's command queue is an idle task. +local function inIdleTask(unitID, commandCount) + for index = 1, commandCount do + if not inIdleTaskAtIndex(unitID, index) then + return false + end + end + return true +end + +local function isIdle(unitID, unitDefID) + -- Taking after base CUnit::IsIdle: + if spGetUnitIsBeingBuilt(unitID) then + return false + end + + if UnitDefs[unitDefID].isFactory then + return spGetFactoryCommandCount(unitID) == 0 + end + + local commandCount = spGetUnitCommandCount(unitID) + return commandCount == 0 or inIdleTask(unitID, commandCount) +end + +return { + IsIdle = isIdle, +} diff --git a/spec/luarules/synthetic_callins_spec.lua b/spec/luarules/synthetic_callins_spec.lua index 35f36d409fe..302f8f79109 100644 --- a/spec/luarules/synthetic_callins_spec.lua +++ b/spec/luarules/synthetic_callins_spec.lua @@ -58,11 +58,36 @@ for _, base in ipairs(BASES) do function gh:UpdateCallIn(name) end function gh:GameFramePost(frameNum) end - local env = setmetatable({ + -- The synced setup includes unit_idle_states, which reads CMD at its own + -- load, so the include has to land in here. It is specced on its own in + -- unit_idle_states_spec; these views only need it to load without error. + local env + env = setmetatable({ gadgetHandler = gh, - Script = { GetSynced = function() return true end }, + Script = { + GetSynced = function() + return true + end, + }, + Spring = setmetatable({ + IsDevLuaEnabled = function() + return false + end, + }, { __index = Spring }), + CMD = { MOVE = 10, REPAIR = 40, OPT_INTERNAL = 8 }, + VFS = setmetatable({ + Include = function(path) + local included = assert(loadfile(path)) + setfenv(included, env) + return included() + end, + }, { __index = VFS }), tracy = { ZoneBeginN = function() end, ZoneEnd = function() end }, - table = setmetatable({ new = function() return {} end }, { __index = table }), + table = setmetatable({ + new = function() + return {} + end, + }, { __index = table }), }, { __index = _G }) local chunk = assert(loadfile(MODULE_PATH)) @@ -242,7 +267,11 @@ for _, base in ipairs(BASES) do end) it("leaves no marks behind a throwing subscriber", function() - local thrower = { [base .. "Post"] = function() error("boom") end } + local thrower = { + [base .. "Post"] = function() + error("boom") + end, + } subscribe(thrower, "Post") mark(101, 0.1) postSeen, totalSeen = {}, {} diff --git a/types/SyntheticCallins.lua b/types/SyntheticCallins.lua index db941a451f1..9d5ea52eaae 100644 --- a/types/SyntheticCallins.lua +++ b/types/SyntheticCallins.lua @@ -3,13 +3,15 @@ ---Call-ins that BAR invents and dispatches from luarules/gadgets.lua. --- ---The summary call-ins report a frame's events once, after the frame. They run ----only when something happened, and each event runs the layer stack in turn, as ----an engine-driven callin does. +---only when something happened, and each event runs the layer stack in order. --- ---Each summary base has a Post and a Total. Both report the same objects in the ---same order, and Total adds the sum of each object's steps. We accumulate the ---sums only while a Total has subscribers, and Post is the cheaper call-in when ---we do not read them. +--- +---A transition base has only a Post, and reports an object once per change in +---its tracked state, rather than once per frame in which it saw an event. ---@class SyntheticCallins --- --- @@ -71,3 +73,14 @@ ---Accumulate: g:AllowFeatureBuildStep, GG.AccumulateFeatureBuildStep. ---Dispatch: g:GameFramePost. ---@field FeatureBuildStepTotal? fun(self, featureID: integer, part: number) +--- +--- +---Runs for every unit that ran out of work, or that found some again. +---Includes exhausted queues, factory queues, and the "idle tasks" that +---units carry out on their own, which do not count as positive work. +--- +---`idled` is `true` when the unit ran out of work, `false` when it found some. +--- +---Mark: g:UnitIdle, g:UnitCommand, g:UnitTaken, g:UnitDestroyed. +---Dispatch: g:GameFramePost. +---@field UnitIdlePost? fun(self, unitID: integer, idled: boolean)