diff --git a/docs/frame-profile-sections.md b/docs/frame-profile-sections.md new file mode 100644 index 000000000..7814e4ce3 --- /dev/null +++ b/docs/frame-profile-sections.md @@ -0,0 +1,34 @@ +# Frame-profile sections for world, presentation and DS audio + +F11 enables the existing `FrameProfile` report. These additional sections use +its shared no-op closer when profiling is disabled; there is no new setting. +All labels begin with spaces, so nested timings do not get counted again as +top-level frame time. Multiple spans with the same label accumulate. + +| Label | Work measured | +| --- | --- | +| `world: tiles` | Initial map/neighbour draws, declined-pipeline fallback and the flat upper layer | +| `world: entities` | Flat ghosts, main and above-layer entities, or tilted billboards | +| `world: FX` | Flat/tilted effect groups, pipeline `ctx.drawFx` and final map overlays | +| `tiles: ensureWindow` | Window validation/refill, including cached and missing-art returns | +| `renderer: zone blit` | Actual `blitCanvas` calls, including the unshaded fallback | +| `renderer: present pipeline` | Present processing and final native output | +| `voxel: sun pass` | Shadow preparation, including cache-hit/rate-limit decisions | +| `voxel: terrain` | Main and neighbouring terrain submission, per eye | +| `voxel: water` | Water draw-list construction, planar reflection and water submission, per eye | +| `voxel: cards` | Main character and staged-battle card submission, per eye | +| `audio: Nitro render` | One DS mixer block, including SoundData creation | + +These are wall-clock submission timings, not GPU execution times. Reflection +copies of cards contribute to the water section; the cards section measures +the visible scene's card pass. The world labels describe selected drawing +phases rather than an exhaustive partition of `drawWorld`; setup, sorting and +other work remain visible in the enclosing section. Voxel timings run inside +the selected world pipeline. Hook names, arguments and dispatch order are +unchanged. An exception still takes its existing cleanup/error path; an +unfinished span is discarded by the existing profiler contract. + +A1 adds observability and makes no performance claim. Desktop before/after +frame timings and ARM/device timings: **not measured**. Capture the same scene, +viewport, camera, mod configuration and profile window when measuring later +optimizations. Profiling itself is disabled by F11 / `FrameProfile.setEnabled(false)`. diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua index 888e4e994..dc5a0786c 100644 --- a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua +++ b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua @@ -14,6 +14,7 @@ local V = ... local Mat4 = V.require("Mat4") local Perf = V.require("Perf") +local FrameProfile = require("src.core.FrameProfile") -- The PERFORMANCE tier's ceilings (lib/Tier.lua). Top-level here rather -- than lazily as in ShadowMap/Water/AntiAlias: Tier requires nothing from -- this mod, so there is no load order to protect, and the sun's rate @@ -2669,8 +2670,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- reaches far north and barely south, which is right for every rung -- but a head free to face south. local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) + local closeSun = FrameProfile.section(" voxel: sun pass") castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, water, nbWater, battleCards, battleToken) + closeSun() -- Everything between beginScene and endScene, as one function: the flat -- path runs it once, a VR frame runs it once PER EYE -- same posed @@ -2678,12 +2681,15 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- about anything but their viewpoint. local function drawScene() + local closeTerrain = FrameProfile.section(" voxel: terrain") Voxel3D.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + closeTerrain() + -- Without a shadow map (headless, or a driver that could not make the -- canvas) the old flat decals stand in: ground-only, characters only, -- but better than a world with nothing under anybody. They go down @@ -2768,6 +2774,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- lake would otherwise wear one as a black smear. Water covers them, -- which is the same answer the shadow map's own pass gives (see -- ShadowMap.sprites) -- people do not shadow water either way. + local closeWater = FrameProfile.section(" voxel: water") local waterDraws = {} if water then waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } @@ -2803,6 +2810,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) planarTex, planarY) end + closeWater() + -- AND THE RIPPLE RINGS ON TOP OF IT, BEFORE ANYBODY IS STANDING IN IT. -- -- THIS LINE'S POSITION IS THE WHOLE FIX. Reported from play: "the ripples @@ -2864,10 +2873,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. + local closeCards = FrameProfile.section(" voxel: cards") drawCast(state, posed, atlasFor) -- and the staged fight's mons over them, exactly as they were painted -- into the water's reflection copy a few lines up (see drawBattleCards) drawBattleCards() + closeCards() -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet diff --git a/src/audio/NitroAudio.lua b/src/audio/NitroAudio.lua index c27fff829..81b0caf87 100644 --- a/src/audio/NitroAudio.lua +++ b/src/audio/NitroAudio.lua @@ -1,5 +1,6 @@ -- Nintendo DS SSEQ transport over the ROM's SBNK/SWAR instruments. -- PCM/ADPCM mixing is local; no replacement MIDI soundfont is used. +local FrameProfile = require("src.core.FrameProfile") local Sdat=require('src.import.Gen4Sdat') local Audio={};local Player={};Player.__index=Player local archives,players={},setmetatable({}, {__mode="k"}) @@ -194,6 +195,7 @@ function Player:tick() for _,v in ipairs(self.voices) do v.ticks=v.ticks-1;if v.ticks<=0 and v.release==0 then v.release=math.floor(RATE*0.04) end end end function Player:render() + local closeRender=FrameProfile.section(" audio: Nitro render") local sound=love.sound.newSoundData(BLOCK,RATE,16,2) for i=0,BLOCK-1 do self.tickPhase=self.tickPhase+self.tempo*48/60/RATE @@ -215,6 +217,7 @@ function Player:render() sound:setSample(i,1,math.max(-1,math.min(1,left))) sound:setSample(i,2,math.max(-1,math.min(1,right))) end + closeRender() return sound end function Player:update() diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..07fafd583 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -6,6 +6,7 @@ -- neighbor filtering. -- Spec: docs/new-features.md (survey zoom) +local FrameProfile = require("src.core.FrameProfile") local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") local PaletteFX = require("src.render.PaletteFX") @@ -686,11 +687,13 @@ end -- render.compose mod can composite the world/UI canvases into its own layout. function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + local closeBlit = FrameProfile.section(" renderer: zone blit") local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil if not shader then love.graphics.setScissor(boxX, boxY, boxW, boxH) love.graphics.draw(canvas, bx, by, 0, sx, sy) love.graphics.setScissor() + closeBlit() return end love.graphics.setShader(shader) @@ -713,6 +716,7 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, end love.graphics.setScissor() love.graphics.setShader() + closeBlit() end -- THE BARS, FILLED WITH THE EDGE THEY FRAME. @@ -1393,6 +1397,7 @@ function Renderer:endFrame(zones, worldZones) end if present then + local closePresent = FrameProfile.section(" renderer: present pipeline") love.graphics.setCanvas() -- Post-process pipelines run over the finished composite -- world, UI -- and all -- and before GBC FX, so a blur or colour grade is what the @@ -1410,6 +1415,7 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(composed, 0, 0) end + closePresent() end self.worldActive = false self.uprightActive = false diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index b82fc4cfb..c55510fc7 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -8,6 +8,7 @@ -- a single static SpriteBatch covering the map plus a border-block ring -- (the ring plays the role of the GB border blocks around small maps). +local FrameProfile = require("src.core.FrameProfile") local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") @@ -1793,6 +1794,7 @@ function TileRenderer:setWin(tx0, ty0, tx1, ty1) end function TileRenderer:ensureWindow(camX, camY, vw, vh) + local closeWindow = FrameProfile.section(" tiles: ensureWindow") local W, H = self.bodyTilesW, self.bodyTilesH vw = vw or W * 8 -- a nil view (headless draw) means the whole body vh = vh or H * 8 @@ -1844,6 +1846,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end if win and tx0 >= win.tx0 and ty0 >= win.ty0 and tx1 <= win.tx1 and ty1 <= win.ty1 then + closeWindow() return -- still inside the last fill end -- refill with margin so the next few scrolled pixels stay covered @@ -1920,6 +1923,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) cells, drew, W, H, tostring(self.gen3.slots)) end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end @@ -1928,6 +1932,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) -- player, and leave the reason in the log (gen3SheetsFor already logged it). if not self.image then self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end if not self.winBatch then @@ -1970,6 +1975,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() end -- draw the static tile window, then its animated overdraw, at the camera offset diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..5d5d65ae8 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -9,6 +9,7 @@ -- surfing, Cut trees, trainer sight lines, and dispatches interactions to -- map scripts (data/scripts/), marts, nurses or extracted text. +local FrameProfile = require("src.core.FrameProfile") local Assets = require("src.render.Assets") local Camera = require("src.render.Camera") local Collision = require("src.world.Collision") @@ -16678,6 +16679,7 @@ function OverworldState:drawWorld() -- path below on the vanilla flat/tilt draw local override if not pipelineId then + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do @@ -16687,6 +16689,7 @@ function OverworldState:drawWorld() if self.g3MirageTower then require("src.world.Gen3MirageTower").drawTower(self, cam) end + closeTiles() end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -17401,6 +17404,7 @@ function OverworldState:drawWorld() -- Deliberately unscaled by depth, like :billboard: an effect keeps its -- crisp authored size and only its anchor moves. ctx.drawFx = function(project, scale) + local closeFx = FrameProfile.section(" world: FX") scale = scale or pscale local colors = ctx.spriteColors() local function at(drawFn, wx, wy) @@ -17471,6 +17475,7 @@ function OverworldState:drawWorld() if pt.active then radarOne = pt; at(fxRadarOne, pt.x * 16 + 8, pt.y * 16 + 16) end end radarOne = nil + closeFx() end override = Pipelines.drawWorld(pipelineId, ctx) -- world post-processes (a miniature-diorama blur, a colour grade) fold @@ -17484,11 +17489,13 @@ function OverworldState:drawWorld() -- was retired). The ground pass was skipped on its behalf above, so -- draw it now and fall through to the flat path below rather than -- compositing an empty canvas. + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + closeTiles() end end @@ -17581,6 +17588,7 @@ function OverworldState:drawWorld() -- The COLLISION GRID IS UNTOUCHED by any of this. It is in map pixels and -- stays there; only the picture is projected, which is why a compressed -- world still walks the same. + local closeGhosts = FrameProfile.section(" world: entities") for _, g in ipairs(self.ghosts) do -- A GHOST GOES THROUGH THE FREE CAMERA TOO. -- @@ -17606,6 +17614,7 @@ function OverworldState:drawWorld() end if not drawn then g.npc:draw(cam.x - g.ox, cam.y - g.oy) end end + closeGhosts() -- HIDDEN MEANS HIDDEN, for the player as much as for anybody. -- -- Two Gen 3 things write this and neither had a reader: the @@ -17909,6 +17918,7 @@ function OverworldState:drawWorld() and groundForRise:beginSprites() then spriteDepthGround = groundForRise end + local closeEntities = FrameProfile.section(" world: entities") for _, e in ipairs(self.entities) do if self:gen3AboveTopLayer(e) then onTop = onTop or {} @@ -17925,6 +17935,7 @@ function OverworldState:drawWorld() spriteDepthGround:endSprites() spriteDepthGround = nil end + closeEntities() -- The Gen 3 top layer goes on AFTER the entity pass and before the -- field effects: it is the half of every metatile the player walks -- behind -- treetops, upper storeys, the far rail of a bridge. On a @@ -17956,6 +17967,7 @@ function OverworldState:drawWorld() self:eachEmote(fxEmote) emoteEarly = true end + local closeTiles = FrameProfile.section(" world: tiles") aim(self.map.renderer, 0, 0) self.map.renderer:drawAbove(cam.x, bgY, vw, vh) aim(self.map.renderer) @@ -17966,8 +17978,12 @@ function OverworldState:drawWorld() aim(nb.map.renderer) end end + closeTiles() + closeEntities = FrameProfile.section(" world: entities") for _, e in ipairs(onTop or {}) do drawEntity(e) end + closeEntities() + local closeFx = FrameProfile.section(" world: FX") fxHeal() fxDust() fxCutTree() @@ -17988,6 +18004,7 @@ function OverworldState:drawWorld() fxRod() -- Smoke keeps the template's priority 0, above the ship and map layers. fxSSAnneSmoke() + closeFx() else -- === TILT PATH: ground-hugging FX stay on the projected ground, all -- standing things billboard upright over it in a separate pass. ====== @@ -17997,6 +18014,7 @@ function OverworldState:drawWorld() -- draws them last, over the sprites, in the same canvas; here the two -- layers are separate and composited ground-under-upright, so drawing -- them now into the still-active ground canvas is order-equivalent. + local closeFx = FrameProfile.section(" world: FX") fxHeal() fxDust() fxCutTree() @@ -18004,6 +18022,7 @@ function OverworldState:drawWorld() -- ...and the gates with them: they are behind every upright sprite (see -- drawGen3Gates), so the ground canvas is exactly where they belong self:drawGen3Gates(cam) + closeFx() Game.renderer:beginUprightPass() @@ -18024,6 +18043,7 @@ function OverworldState:drawWorld() end table.sort(items, function(a, b) return a.y < b.y end) + local closeEntities = FrameProfile.section(" world: entities") for _, it in ipairs(items) do if it.kind == "ghost" then -- ghosts billboard just like real entities (foot offset folds in the @@ -18059,6 +18079,8 @@ function OverworldState:drawWorld() end end + closeEntities() + closeFx = FrameProfile.section(" world: FX") -- Standing world FX: each billboards at the ground foot of the -- character it belongs to, so it stays upright over the tilted ground. -- emote bubble -> the spotting NPC's foot (rides above its head) @@ -18100,6 +18122,7 @@ function OverworldState:drawWorld() radarOne = nil Game.renderer:endUprightPass() + closeFx() end -- LAST IN THE WORLD PASS, over everything the cave contains: the black @@ -18107,6 +18130,7 @@ function OverworldState:drawWorld() -- drawUI because it is anchored to the PLAYER, not to the screen -- the -- cartridge can put it at a fixed (120,80) because its camera never lets -- the player leave the middle, and this port's does at a map edge. + local closeFx = FrameProfile.section(" world: FX") self:drawGen3Flash(cam, vw, vh) -- the Mirage Tower's falling fossil and ceiling crumbs: OAM priority 0 if self.g3MirageTower or self.g3MirageCrumbles then @@ -18118,6 +18142,7 @@ function OverworldState:drawWorld() -- in the world pass rather than the UI pass for the same reason the flash -- is: it is anchored to a CELL, not to the screen. self:drawGen3Decorate(cam) + closeFx() end -- The cursor the DECORATE row hands the player: the decoration's own