The First Shot in Every Run Hitched, and the Second Didn't
Textures upload to the GPU on first draw, which in a shooter means the first time you pull the trigger. Dead Signal now pushes them through during the warm frame instead.
There was a frame in Dead Signal I could feel before I could measure it. Start a run, line up the first husk, fire, and the game would skip. Once. Never again that run. A hitch that only happens once per session is the hardest kind to care about and the easiest to ignore, and I ignored it for a while.
It was texture uploads. three.js does not send a texture to the GPU when you load it. It sends it the first time something draws with it. The gun in your hands draws from the first frame, but the fire effects and the gore materials do not draw until you shoot. Each of those pulls a texture across the bus mid-frame, and a 2K map is not free. The first husk to die paid for the gore atlas. The first shot paid for everything else.
The first pass fixed the guns and the viewmodel. three has renderer.initTexture, which uploads a texture now and makes the first draw a plain bind. The GLB loader walks every material it produces, map, normal, metalness, roughness, ao, and pushes each through initTexture as the model lands. The same commit found a GC problem next to it: the stage was sharing one material between plain meshes and instanced meshes, which made three rebuild its program parameters constantly, so propMat split into propMat and propInstMat. And the transparent double-sided materials for gore, fx, fire and the supply cache got forceSinglePass, because without it three draws them in two passes and updates the material twice.
That left the arms. The skinned arms are a separate rig with their own map, normal and ORM textures, and they load on a different schedule from the guns: guns wait for prefetch after the title screen, arms used to wait with them. So on the first in-fight frame the arms were still uploading.
The fix was small. ArmsRig got initTextures, which loops its three textures through renderer.initTexture. warmFrame, the frame deploy() runs with simulation stopped after precompiling the shaders, calls it through the ViewModel. And setAssets now starts the arms load immediately rather than waiting for prefetch, so by the time warmFrame runs the arms are usually ready and the upload lands inside the deploy window where it is already paid for.
The upload still happens. It just happens inside the deploy window, behind a screen that is already allowed to take time, instead of on the first trigger pull. What I learned: a warm frame is only as warm as the things you remember to draw into it. If an asset first appears on a player action, the player will feel its upload. Find every texture that waits for a trigger and upload it before the trigger exists.