Stormworks: Build and Rescue

Stormworks: Build and Rescue

Not enough ratings
Vectors, In Lua [HMD, HUD, etc.]
By Kernle Frizzle
This guide makes use of a vector "library," simplifying the syntax for various vector operations. You can take the operations as a template to apply using your own library, or save the library included in this guide.
   
Award
Favorite
Favorited
Unfavorite
Context
This guide provides a series of Lua functions that will generate vector objects, which can then be used to compute coordinate transforms and projections.

If you are planning on building a head-mounted display (HMD), heads-up display (HUD) or viewing scope system that projects 3D points onto a 2D plane, these functions will be essential.

I'll assume you have some experience with vectors, as well as functions/methods in Lua, so I will keep the explanations of each function to a minimum.

These functions use a left-handed coordinate system for continuity with the physics sensor component. The vector functions should be cross-compatible with a right-handed system, but the basis generating function and the HUD projection functions would need a tweak.

Rotation for polar derivatives and axis-angle is left-handed.

For more context on Euler angles and basis changes, this guide has it all.
Vector Library
Standard Vector And Basis Functions:
-- Vector: function vc(x,y,z)return{ x or 0,y or 0,z or 0, mag=function (a)local m=a[1]^2+a[2]^2+a[3]^2return m==1 and 1 or math.sqrt(m)end, dot=function (a,b)return type(b)=="table"and a[1]*b[1]+a[2]*b[2]+a[3]*b[3]or vc(a[1]*b,a[2]*b,a[3]*b)end, cross=function (a,b)return vc(a[2]*b[3]-a[3]*b[2],a[3]*b[1]-a[1]*b[3],a[1]*b[2]-a[2]*b[1])end, add=function (a,b)return vc(a[1]+b[1],a[2]+b[2],a[3]+b[3])end, sbt=function (a,b)return vc(a[1]-b[1],a[2]-b[2],a[3]-b[3])end, norm=function (a)return a:dot(1/a:mag())end, ltog=function (a,bm)return bm[1]:dot(a[1]):add(bm[2]:dot(a[2])):add(bm[3]:dot(a[3]))end, gtol=function (a,bm)return vc(a:dot(bm[1]),a:dot(bm[2]),a:dot(bm[3]))end, axang=function (a,ax) local bi,bj=a:cross(ax):norm(),ax:norm() local bk=bj:cross(bi) bi,bk=vc(math.cos(ax:mag()),0,-math.sin(ax:mag())):ltog({bi,bj,bk}),vc(math.sin(ax:mag()),0,math.cos(ax:mag())):ltog( {bi,bj,bk}) return ax:mag()~=0 and vc(0,ax:norm():dot(a),ax:norm():cross(a):mag()):ltog({bi,bj,bk}) or a end, disp=function (a,headtohud,hudnorm,fact) local t=a:dot(hudnorm:dot(headtohud)/hudnorm:dot(a)):sbt(headtohud) return t[1]*fact,-t[2]*fact end, dsimp=function (a,fact) return a[1]/a[3]*fact,-a[2]/a[3]*fact end, }end -- Basis: function ijkb(x,y,z,t) local sx,cx,sy,cy,sz,cz=math.sin(x),math.cos(x),math.sin(y),math.cos(y),math.sin(z),math.cos(z) return t~="aer"and{vc(cy*cz,cy*sz,-sy),vc(sx*sy*cz-cx*sz,sx*sy*sz+cx*cz,sx*cy),vc(cx*sy*cz+sx*sz,cx*sy*sz-sx*cz,cx*cy)}or{vc(cz*cx+sz*sy*sx,-sz*cy,sz*sy*cx-cz*sx),vc(sz*cx-cz*sy*sx,cz*cy,-cz*sy*cx-sz*sx),vc(sx*cy,sy,cx*cy)} end function ijk(...)return table.unpack(ijkb(...))end function fullaxang(bm,a)return{bm[1]:axang(a),bm[2]:axang(a),bm[3]:axang(a)}end function fullltog(b1,b2) return {b1[1]:ltog(b2),b1[2]:ltog(b2),b1[3]:ltog(b2)} end function fullgtol(b1,b2) return {b1[1]:gtol(b2),b1[2]:gtol(b2),b1[3]:gtol(b2)} end
Calculus:
function vcalc()return{ p=vc(0,0,0), s=vc(0,0,0), lindv=function (a,i) local d=i:sbt(a.p) a.p=i return d end, poldv=function (a,i) local d,na,ni=i:mag()-a.p:mag(),a.p:norm(),i:norm() local cv=ni:cross(na) local v=cv:norm():dot(math.atan(cv:mag(),na:dot(ni))) a.p=i return v,d end, int=function (a,i) a.s=a.s:add(i) return a.s end, }end function fullpoldv()return{ bm={vc(),vc(),vc()}, calc=function (s,bm) local di,dj,dk=bm[1]:cross(s.bm[1]),bm[2]:cross(s.bm[2]),bm[3]:cross(s.bm[3]) s.bm=bm local mix=di:add(dj):add(dk):dot(1/2) return mix:norm():dot(math.asin(mix:mag())) end, }end
Function For Receiving Vectors As Property Texts
function getvc(si) s=property.getText(si) local pi,t=1,{} for i=1,s:len() do if s:sub(i,i)=="," then t[#t+1]=tonumber(s:sub(pi,i-1)) pi=i+1 elseif i==s:len() then t[#t+1]=tonumber(s:sub(pi,-1)) end end return vc(table.unpack(t)) end
Documentation
Regular Vectors
  • a = vc(x,y,z) -- Returns a vector object in the form {x,y,z} that you can then modify with the following methods. x, y, z = [1], [2], [3]
  • a:mag() -- Returns the magnitude of the vector
  • a:dot(b) -- Returns the dot product a • b
  • a:cross(b) -- Returns the cross product a ⨉ b
  • a:add(b) -- Returns the sum a + b
  • a:sbt(b) -- Returns a - b
  • a:norm() -- Returns a unit vector, a / ||a||
  • a:axang(ax) -- Returns the vector a rotated around the axis ax, who's magnitude represents the size of the rotation in radians, left-handed
  • a:disp(headtohud,hudnorm,fact) -- Given the offset from the player's head to the center of the hud (headtohud), the normal vector of the face of the hud relative to the vehicle (hudnorm, normally vc(0,0,1) unless xml'd to be skewed differently), and a factor to multiply the resulting of the projection from 3d coordinates onto the 2d hud by (fact) (x and y aren't scaled with the hud's normal, i.e. with a hudnorm of vc(0,1,1) pixel y would be scaled by a factor of √2 when looking at the plane head-on, drawn as if there was no skew)
  • a:dsimp(fact) -- Given a factor normally derived from FOV (fact), this returns the simple perspective projection from a 3d coordinate to the 2d screen, assuming coordinates are local to the screen with x and y representing the x and y axes of the screen
Basis Generation
  • ijkb(x,y,z,t) -- Returns a vector basis in the form {i,j,k} (notated in other functions as "bm"), x, y and z are the left-handed angles around the x, y and z axes, t is an optional parameter that, when set to "aer," changes the rotation order of the function from standard physics sensor Euler angles to azimuth, elevation and roll
  • ijk(x,y,z,t) -- Returns the same as ijkb, except in the form i, j, k as separate variables
Basis Transforms
  • a:ltog(bm) -- Returns the vector a transformed from its local basis to global coordinates, bm represents the local basis
  • a:gtol(bm) -- Returns the vector a transformed from global coordinates to a local basis, bm represents the local basis
  • fullltog(b1,b2) -- Returns the whole basis b1 transformed from its local basis to global coordinates, b2 represents the local basis
  • fullgtol(b1,b2) -- Returns the whole basis b1 transformed from global coordinates to a local basis, b2 represents the local basis
Discrete Calculus (vector and basis)
  • vecCalc = vcalc() -- Returns an object that allows calculus to be done, for the rest of this list its object will be called "vecCalc"
  • vecCalc:lindv(a) -- Returns the cartesian coordinate delta of the vector a as a vector
  • vecCalc:poldv(a) -- Returns the polar coordinate delta of the vector a as two values v, d (v is the axis around which the vector rotated, left-handed with the same magnitude as the rotation, and d is the change in magnitude)
  • vecCalc:int(a) -- Returns the integral of the vector a over time, i.e. a is added to the value returned every tick
  • Note -- you can only use either lindv OR poldv in a single vcalc object at the same time, as they share the same past value; good practice is to create a new vcalc object for every calculus operation you need to do
  • basisCalc = fullpoldv() -- Returns an object that contains a function to calculate the axis-angle (poldv) delta of an entire basis, for the rest of this list its object will be called "basisCalc"
  • basisCalc:calc(bm) -- Returns the axis-angle (poldv) delta of the basis bm as a vector, left-handed with the same magnitude as the magnitude of the rotation
  • Note -- to save space, this function isn't mathematically complete; it will only return useful data if the magnitude of the rotation in one tick is less than π (180°)
(supplimental) Inputting Vectors Using Property Text
  • getvc(s) -- Returns the vector who's components are written in the property text with the name s (string), in the form xCoord,yCoord,zCoord (EX: "1,0,-3")
Common Uses (RADAR/LiDAR & HMD/Scope)
RADAR/LiDAR
Anything mentioning "radar" can be replaced with "lidar," the systems work the same

The hardest thing a beginner has to face when making a radar system is the conversion from a target's local coordinates to global GPS coordinates. With the functions in this library, the process is streamlined for maximum ease-of-use.

To start, you first define the physics sensor's, and therefore the vehicle's, local basis. This is done with the ijkb() function:
vehicleBasis = ijkb(eulerX, eulerY, eulerZ)
With this basis, coordinates can be converted local -> global and global -> local at will.

Because ijkb() returns a basis, which consists of three vectors in a table, ordered i, j and k, you can use ijkb() with the optional "aer" parameter to calculate the initial coordinates of a radar target:
k = ijkb(targetAzimuth*math.pi*2, targetElevation*math.pi*2, 0, "aer")[3] localTarget = k:dot(targetDistance)
The ijkb() function is used to generate the k vector we need to describe the direction to the target, then with that k vector (extracted with [3], as a basis is just {i,j,k}) we can calculate the local coordinates by extending that vector out to be the length of targetDistance, hence k:dot(targetDistance).

To then convert these local coordinates to global coordinates, you write the following single statement:
globalTarget = localTarget:ltog(vehicleBasis)
globalTarget will now be the global coordinates for the radar target. To get the GPS coordinates from here, add the physics sensor's current GPS to the target's global coordinates:
targetGPS = globalTarget:add(vc(gpsX, gpsY, gpsZ))
The physics sensor GPS coordinates have to be made into a vector, then added to globalTarget to get the target's actual GPS coordinates.

HMD
With the release of the HMD mid-February 2025, there's been more interest in methods for displaying 3D coordinates in your view.

Same as the radar/lidar, the first step is to define the physics sensor's basis:
vehicleBasis = ijkb(eulerX, eulerY, eulerZ)
Then, for later, we need to define the basis that describes the difference in orientation between your current seat look and the vehicle using ijkb() with the optional "aer" parameter:
localLookBasis = ijkb(seatX*math.pi*2, seatY*math.pi*2, 0, "aer")
This basis is technically describing the basis change between the vehicle's basis and your look's basis, hence the name localLookBasis. It assumes that the vehicle's basis is the "global" basis, when in actuality it is local as well.

Next, we can go a couple of routes. We can either convert the global GPS coordinates to local coordinates first, then transform those coordinates local to the vehicle onto the basis for your current look:
localTarget = vc(targetGPSx,targetGPSy,targetGPSz):gtol(vehicleBasis) localTarget = localTarget:gtol(localLookBasis)
OR you can use one of the full basis transform functions to transform one entire basis onto another, leaving you with a basis that describes the difference between true global and your current look:
globalLookBasis = fullltog(localLookBasis, vehicleBasis) localTarget = localTarget:gtol(globalLookBasis)
This method is preferable when multiple targets or coordinates have to be transformed. Pre-calculating the final rotation by transforming one basis onto the other lets you then apply both of those rotations using a single transformation. If you've worked with matrices before, this is the same as chaining rotations by multiplying one matrix by another.

It is important to keep track of when to use fullltog() v.s. fullgtol(). The have very different results, though I'm sure they're closely linked in a way that is too much to think about right now.

However, for the HMD (and any other HUD) specifically, you need to add a step where you shift the target's local coordinates to be relative to your head rather than the physics sensor, and you therefore need to use the first method outlined above. The first step is to find a function that will describe your head's voxel position. The best function I've found to do this is:
if male then headh=210/325 else headh=49/276 end headpos=vc( clamp(sx/0.275,-1,1)*7/18, clamp(sy/0.125,-1,0)*(210/325-9/36)+clamp(sy/0.125,0,1)*(210/325-12/36)+headh-1/16-1/2, clamp(clamp(sy/0.125,0,1)*(74/242-214/375)+214/375,0,624/1000)-1/2 ):dot(1/4)
where male is your character's gender, sx and sy are seat x and y look, and headpos is a vector describing the head's offset from the center of its voxel, in meters thanks to the :dot(1/4). clamp() is a function that works the same way as the clamp() in any function logic block, and has to be user-defined as math.min(math.max(x,l),u).

From here, we then need a vector that describes the offset from the physics sensor to your head's center voxel. This is the primary use of the getvc() property function:
phystohead = getvc("vector from physics sensor to head voxel, meters")
Then, you need to shift the target's coordinates local to the vehicle to be relative to your head's position, with:
localTarget = localTarget:sbt(phystohead):sbt(headpos)
specifically before transforming onto your look's basis.

Now that you have your local coordinates to your current look, you need to convert them to pixel coordinates for displaying the target on the HMD. Because the HMD is a simple flat screen overlayed in your vision, you can use dsimp() rather than disp(). I'm sure you can calculate the exact factor you need to enter for it to be accurate, but for now you can safely say it's in the range of 170-175:
px, py = localTarget:dsimp(170)
px and py will now be the pixel coordinates of the target in your view.

However, this approach has one caveat. If the target is behind you, the pixel coordinates can still be calculated, and will manifest themselves in the same way as a target ahead in your view, only with inverted x and y. To eliminate this effect, you can include a check before drawing your marker:
sw = screen.getWidth() sh = screen.getHeight() if localTarget[3] > 0 then px, py = localTarget:dsimp(170) screen.drawRectF(px+sw/2, -py+sh/2, 1, 1) end
Here, py is negative thanks to the monitor drawing from top-down. The y coordinate has to be inverted to match that behavior.

Viewing Scope
Here, the approach is almost exactly the same as for the HMD. The only differences are in the look inputs and the scaling factor for the dsimp() pixel values.

Instead of seat look, the look basis will use the current camera deflection as its inputs:
localLookBasis = ijkb(camX/4*math.pi, camY/4*math.pi, 0, "aer")
Then, you would need to base your factor off of the camera's current FOV:
sw = screen.getWidth() factor = sw/2/math.tan(fov/2)
where fov is the current fov of the camera in radians, which can be found with the formula:
fov = 135/180*math.pi+zoom*(0.025-135/180*math.pi)
which effectively lerps between the value of 135 degrees and 0.025 radians, the range of the camera's zoom as we know it.

A physical HUD is actually more complicated than the HMD or scope.
HUGE new info, courtesy of Jumper
clamp=function(x,s,l)return x<s and s or x>l and l or x end tau=math.pi*2 --isFemale = boolean, character gender --look x, y are directly from seat headAzimuthAng = clamp(lookX, -0.277, 0.277) * 0.408 * tau -- 0.408 is to make 100° to 40.8° headElevationAng = clamp(lookY, -0.125, 0.125) * 0.9 * tau + 0.404 + math.abs(headAzimuthAng/0.7101) * 0.122 -- 0.9 is to make 45° to 40.5°, 0.404 rad is 23.2°. 0.122 rad is 7° at max yaw. distance = math.cos(headAzimuthAng) * 0.1523 -- 0.1523 radius of sphere vectorHeadPos = vc( math.sin(headAzimuthAng) * 0.1523, math.sin(headElevationAng) * distance -(isFemale and 0.141 or 0.023), math.cos(headElevationAng) * distance +(isFemale and 0.132 or 0.161) )
Massive credit to Jumper in the Stormworks discord for this nugget of knowledge (relayed by FestiveBox)

This function will calculate the true position of your head in a seat (in meters). I have no idea how he got this information, but I'm not complaining.

Strangely, this function does do some of the typical sin and cos stuff you would expect for look-to-vector, but in a strange order and with some strange input scaling. I have no idea why the developers made it this way, I can only assume it makes your head feel more natural than the alternatives.
Physical HUD Projection, :disp()
HUD
Once again, a HUD will require the transformation from global coordinates to local coordinates:
vehicleBasis = ijkb(eulerX, eulerY, eulerZ) localTarget = vc(targetGPSx,targetGPSy,targetGPSz):gtol(vehicleBasis)
unless you already have coordinates local to the vehicle, i.e. the return from a radar or laser sensor.

Because the HUD itself doesn't change its orientation when you change your look, the target's projection onto the HUD's plane will be the same no matter where you look. The only thing you need to consider is your head's current position relative to the HUD.

For that, you need to use the math shown earlier for your head's position. Then, you will need to use the disp() function:
headtohud = getvc("vector from your head to the center of the hud's plane, meters"):sbt(headpos) hudnorm = getvc("hud's plane normal vector") factor = 650 px, py = localTarget:disp(headtohud,hudnorm,factor)
This display function will have the same quirk as the other when displaying targets behind you, so you will need to add the same check for targpos[3] > 0. Rather than simply dividing each coordinate by the forward distance for perspective, this function calculates the coordinates of the intersection between the plane of the hud (from the normal vector) and the vector from your head to the target. In this case, factor is the conversion between meters and physical pixels on the component. From my testing, 650 seems close enough.

The HUD's plane normal vector is a vector that represents the normal to the plane of the HUD. For a standard flat hud, no xml, this vector would be vc(0,0,1). For a hud angled with the top closer to you than the bottom at a 45 degree angle, this vector would be vc(0,1,1). It closely follows the xml transform.

The vector from your head to the center of the hud's plane is self-explanatory.

You will still need to add sw/2 and sh/2 to each coordinate, and invert the y coordinate depending on the orientation of the HUD on your vehicle:
sw = screen.getWidth() sh = screen.getHeight() if localTarget[3] > 0 then headtohud = getvc("vector from your head to the center of the hud's plane, meters"):sbt(headpos) hudnorm = getvc("hud's plane normal vector") factor = 650 px, py = localTarget:disp(headtohud,hudnorm,factor) screen.drawRectF(px+sw/2, -py+sh/2, 1, 1) end
like comment subscribe
Many many many other systems can be made with these functions, including 3rd person look converters and guidance / control computers. I have used the fullpoldv() function to great effect in a custom helicopter gyro in place of the physics sensor's angular speed outputs, and have also used it to make up for tick delay in the roll and orientation of an HMD / viewing scope.

If you have any questions, or anything needs clarification, don't hesitate to ask in the comments.

Hey.

I’m applying for a new villain loan, go by the name of… Vector.

It’s a mathematical term, a quantity represented by an arrow with both direction and magnitude.

VECTOR! That’s me, ‘cause I’m committing crimes, with both direction... and… MAGNITUDE! OH YEAH!

Check out my new weapon. PIRANHA GUN! OH YES! Fires live piranhas. Ever seen one before? No you haven’t, I invented it. Do you want a demonstration?
1 Comments
MrMereScratch 23 Feb @ 5:46pm 
with both direction and magnitude