Skip to content

Commit 0f857bc

Browse files
committed
freecam: Smoothen camera movement
While investigating "Camera calculation not working properly on 1.7 #4451" I noticed that camera movement is very uneven compared to your mouse movement. Like making a circle with the cursor is easy, but making the same circle with freecam is much harder. I found that this was because onClientCursorMove events would come in all over the place, so I asked ChatGPTv5 to make improvements that would smoothen this out and it has done a good job. But if you use a custom build of MTA, the mouse movement is better, but still bad.
1 parent b07150d commit 0f857bc

File tree

1 file changed

+92
-36
lines changed

1 file changed

+92
-36
lines changed

[editor]/freecam/freecam.lua

Lines changed: 92 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ end
6464

6565
-- PRIVATE
6666

67-
local function freecamFrame ()
67+
local function freecamFrame (deltaTime)
68+
freecamMouseApply(deltaTime)
6869
-- work out an angle in radians based on the number of pixels the cursor has moved (ever)
6970
local cameraAngleX = rotX
7071
local cameraAngleY = rotY
@@ -217,48 +218,103 @@ local function freecamFrame ()
217218
setCameraMatrix ( camPosX, camPosY, camPosZ, camTargetX, camTargetY, camTargetZ, 0, options.fov )
218219
end
219220

220-
local function freecamMouse (cX,cY,aX,aY)
221-
--ignore mouse movement if the cursor or MTA window is on
222-
--and do not resume it until at least 5 frames after it is toggled off
223-
--(prevents cursor mousemove data from reaching this handler)
224-
if isCursorShowing() or isMTAWindowActive() or (not isMTAWindowFocused()) then
225-
mouseFrameDelay = 5
226-
return
227-
elseif mouseFrameDelay > 0 then
228-
mouseFrameDelay = mouseFrameDelay - 1
229-
return
221+
-- Internal state (module-level)
222+
local mouseFrameDelay = 0 -- frames to ignore after cursor/window toggles
223+
local accumDX, accumDY = 0, 0 -- accumulated raw mouse deltas (pixels)
224+
local lastEventTick = 0
225+
local PI, RAD = math.pi, math.pi / 180
226+
227+
-- Tunables (adjust to taste)
228+
local RESUME_FRAMES = 5 -- frames to wait after cursor/window active
229+
local DEADZONE_PX = 0.15 -- ignore tiny jitters
230+
local MAX_EVENT_DELTA = 200 -- clamp a single event spike (px)
231+
local APPLY_K_60FPS = 0.42 -- how quickly to drain accumulator at 60fps (0..1)
232+
233+
-- Normalize angle to [-PI, PI]
234+
local function normPI(a)
235+
a = a % (2 * PI)
236+
if a > PI then
237+
a = a - 2 * PI
230238
end
239+
return a
240+
end
231241

232-
-- how far have we moved the mouse from the screen center?
233-
aX = aX - width / 2
234-
aY = aY - height / 2
242+
function freecamMouse(cX, cY, aX, aY)
243+
-- Gate input when the UI is up / focus lost
244+
if isCursorShowing() or isMTAWindowActive() or (not isMTAWindowFocused()) then
245+
mouseFrameDelay = RESUME_FRAMES
246+
accumDX, accumDY = 0, 0
247+
return
248+
elseif mouseFrameDelay > 0 then
249+
mouseFrameDelay = mouseFrameDelay - 1
250+
accumDX, accumDY = 0, 0
251+
return
252+
end
235253

236-
--invert the mouse look if specified
237-
if options.invertMouseLook then
238-
aY = -aY
239-
end
254+
-- How far from screen center?
255+
local dx = (aX - width * 0.5)
256+
local dy = (aY - height * 0.5)
240257

241-
rotX = rotX + aX * options.mouseSensitivity * 0.01745
242-
rotY = rotY - aY * options.mouseSensitivity * 0.01745
258+
-- Optional invert
259+
if options.invertMouseLook then
260+
dy = -dy
261+
end
243262

244-
local PI = math.pi
245-
if rotX > PI then
246-
rotX = rotX - 2 * PI
247-
elseif rotX < -PI then
248-
rotX = rotX + 2 * PI
263+
-- Deadzone + spike clamp
264+
if math.abs(dx) < DEADZONE_PX then
265+
dx = 0
249266
end
250-
251-
if rotY > PI then
252-
rotY = rotY - 2 * PI
253-
elseif rotY < -PI then
254-
rotY = rotY + 2 * PI
267+
if math.abs(dy) < DEADZONE_PX then
268+
dy = 0
269+
end
270+
if dx > MAX_EVENT_DELTA then
271+
dx = MAX_EVENT_DELTA
272+
elseif dx < -MAX_EVENT_DELTA then
273+
dx = -MAX_EVENT_DELTA
255274
end
256-
-- limit the camera to stop it going too far up or down - PI/2 is the limit, but we can't let it quite reach that or it will lock up
257-
-- and strafeing will break entirely as the camera loses any concept of what is 'up'
258-
if rotY < -PI / 2.05 then
259-
rotY = -PI / 2.05
260-
elseif rotY > PI / 2.05 then
261-
rotY = PI / 2.05
275+
if dy > MAX_EVENT_DELTA then
276+
dy = MAX_EVENT_DELTA
277+
elseif dy < -MAX_EVENT_DELTA then
278+
dy = -MAX_EVENT_DELTA
279+
end
280+
281+
-- Accumulate; application to rot happens in freecamMouseApply() every frame
282+
accumDX = accumDX + dx
283+
accumDY = accumDY + dy
284+
lastEventTick = getTickCount()
285+
end
286+
287+
function freecamMouseApply(deltaTime)
288+
-- If UI pops up mid-frame, bail early
289+
if isCursorShowing() or isMTAWindowActive() or (not isMTAWindowFocused()) then
290+
mouseFrameDelay = RESUME_FRAMES
291+
accumDX, accumDY = 0, 0
292+
return
293+
end
294+
295+
-- Frame-rate–independent smoothing: convert APPLY_K_60FPS to current deltaTime
296+
-- factor = 1 - (1 - k)^(deltaTime * 60ms^-1)
297+
local factor = 1 - ((1 - APPLY_K_60FPS) ^ math.max(deltaTime / 16.666, 0.001))
298+
299+
-- Take a smooth chunk out of the accumulator
300+
local useDX = accumDX * factor
301+
local useDY = accumDY * factor
302+
accumDX = accumDX - useDX
303+
accumDY = accumDY - useDY
304+
305+
-- Convert pixels -> radians (sensitivity is in degrees/pixel)
306+
local rpp = (options.mouseSensitivity or 1) * RAD
307+
308+
-- Apply to camera (note Y is typically "pitch" and inverted vs screen Y)
309+
rotX = normPI(rotX + useDX * rpp)
310+
rotY = rotY - useDY * rpp
311+
312+
-- Clamp pitch to avoid gimbal lock / upside-down strafing
313+
local limit = PI / 2.05
314+
if rotY < -limit then
315+
rotY = -limit
316+
elseif rotY > limit then
317+
rotY = limit
262318
end
263319
end
264320

0 commit comments

Comments
 (0)