Further optimization could be possible if I found a way to divide the trace results into segments based on intersections with water volumes. This would save on countless calls to Vec3Offset and PointInSector. Something to keep in mind, I suppose.
While I was at it, I did make the underwater check into a new utility function. It is more or less based on how updating water level is done internally, but saves time by just checking a single point rather than an actor's full height.
Not sure if I'll need to use it elsewhere, but if that turns out to not be the case I'll change it to a private function of the SWWMBulletTrail class afterwards.