RBX: How to accurately yield/pause script up to miliseconds?

68 Views Asked by At

Alright, so basically I have a script that draws ECG on a SurfaceGui with pixels (frames), and the thing is, the script runs very slowly, like twice or thrice slower than supposed to, like when I need script to draw a certain wave with length 80 ms, it draws it in 160 ms or more.

I figured out to use os.clock() to see what is going on, and found out that wait() and task.wait() become very inaccurate at very low wait times (e.g. 1/60 seconds).

I tried to use the same os.clock() to accurately measure time. It worked on paper fine, but on practice it made my PC lag and constantly cause Script exhausted execution time error. I was using this function:

local function AccurateWait(time)
    local start = os.clock()
    while os.clock() - start < time do 
        -- Nothing
    end
end

Now I don't know what to do, here is full script, maybe it will help. I need to make script wait exactly 1 frame (at 60 FPS) or 0.017 seconds.

local leadFrame = script.Parent.blackBg.I_leadFrame
local leadFrameWidth = leadFrame.Size.X.Offset
local leadFrameUpdateTick = 1

local pixelSize = 2 -- def: 
local pixels = {}

local step = 3 -- DO NOT CHANGE THIS VALUE, def: 4

local colGreen = Color3.fromRGB(25, 255, 25)

local heartBPM = 60 / 60 * 1000 -- Will need it later, for now let it remain as 60

local linePosX = 0
local linePosY = 0
local linePosY_Scale = 0.5
local lineAccuracy = 16 -- def: 16
local lineReachedFrameWidth = false
local lineLoops = true
local lineClearanceOffset = 20
local lineClearing = false

local section = 0
local sectionInMS = 0
local sectionMaxWidth = 0

local wholeBeatLength = 0

local function DrawLine()
    if linePosX >= leadFrameWidth - pixelSize then
        if lineLoops then
            linePosX = 0
        end
        lineReachedFrameWidth = true
        return
    end
    
    if linePosX >= leadFrameWidth - pixelSize - lineClearanceOffset or lineClearing then
        lineClearing = true
        pixels[1]:Destroy()
        table.remove(pixels, 1)
    end
    
    if linePosY ~= linePosY then
        linePosY = 0
    end
    
    local pixel = Instance.new("Frame", leadFrame)
    pixel.Size = UDim2.new(0, pixelSize, 0, pixelSize)
    pixel.Position = UDim2.new(0, linePosX, linePosY_Scale, linePosY)

    pixel.BackgroundColor3 = colGreen
    pixel.BackgroundTransparency = 0
    pixel.BorderSizePixel = 0
    pixel.Name = "pixel"
    
    table.insert(pixels, pixel)
end

local function DrawP_Wave()
    local durationP_Wave = 80 -- In ms, assume it is normal duration at 60 bpm
    local durationPR_Segment = durationP_Wave + 40
    
    local startTime = os.clock()
    while sectionInMS < durationP_Wave do
        -- At these parameters length of P wave is 90 ms
        local A = 15 * step / 4 -- Scale of P wave, def: 15
        local B = 2.4 -- Width of P wave, def: 2.2 (the higher - the shorter)
        local C = 1.5 -- Can't describe, better not touch it, def: 1.5
        local D = 0.4 -- Height of P wave, def:
        local E = 1 -- Polarity of P wave, def: 1
        
        for i = 1, lineAccuracy do
            linePosY = -E * (A * D * math.sin(B * section / A))^C
            
            DrawLine()
            
            linePosX += step/lineAccuracy
            section += step/lineAccuracy
            sectionInMS = ((section/(60*step))*1000)
            
            if sectionInMS > durationP_Wave then
                break
            end
        end
        wait(1/60)
    end
    print("P wave: "..((os.clock()-startTime)*1000).." ms")
    
    while sectionInMS < durationPR_Segment do
        for i = 1, lineAccuracy do
            DrawLine()

            linePosX += step/lineAccuracy
            section += step/lineAccuracy
            sectionInMS = ((section/(60*step))*1000)
            
            if sectionInMS > durationPR_Segment then
                break
            end
        end
        wait(1/60)
        wholeBeatLength += 1/60*1000
    end
    print("PQ segment: "..((os.clock()-startTime)*1000).." ms")
    
    section = 0
    sectionInMS = 0
end

local function DrawQRS_Complex()
    local durationQRS_Complex = 90
    local durationST_Segment = 100 + durationQRS_Complex
    
    local startTime = os.clock()
    while sectionInMS < durationQRS_Complex do
        local A = 1.7 -- Width of QRS, def: 1.5 (the higher A - the shorter QRS)
        local B = 1.7 -- Height of QRS, def: 1.7
        local C = 3.6
        local D = 3.5
        local E = 5 -- def: 5
        local F = 1.1 -- Proportions of Q to S (bigger num -> deeper peak Q), def: 1.1
        local G = 15 * step / 4 -- Scale, def: 15
        
        for i = 1, lineAccuracy do
            linePosY = -G*((B*(math.sin(A/G * section))^E)^D-
                       (math.sin(A/G*F * section))^C)
            
            DrawLine()
            
            linePosX += step/lineAccuracy
            section += step/lineAccuracy
            sectionInMS = ((section/(60*step))*1000)
            
            if sectionInMS > durationQRS_Complex then
                break
            end
        end
        wait(1/60)
        wholeBeatLength += 1/60*1000
    end
    print("QRS complex: "..((os.clock()-startTime)*1000).." ms")
    
    while sectionInMS < durationST_Segment do
        for i = 1, lineAccuracy do
            DrawLine()

            linePosX += step/lineAccuracy
            section += step/lineAccuracy
            sectionInMS = ((section/(60*step))*1000)
            
            if sectionInMS > durationST_Segment then
                break
            end
        end
        wait(1/60)
        wholeBeatLength += 1/60*1000
    end
    print("ST segment: "..((os.clock()-startTime)*1000).." ms")
    
    section = 0
    sectionInMS = 0
end

local function DrawT_Wave()
    local durationT_Wave = 160
    
    local startTime = os.clock()
    while sectionInMS < durationT_Wave do
        local A = 1.1
        local B = 1.6
        local C = 3.6
        local D = 0.9
        local E = 5
        local F = 1.1
        local G = 15 * step / 4
        local H = 2.1
        
        for i = 1, lineAccuracy do
            linePosY = -G*((B*(math.sin(A/G * section))^E)^D-
                ((math.sin(A/G*F * section))^H)^C)
            
            DrawLine()
            
            linePosX += step/lineAccuracy
            section += step/lineAccuracy
            sectionInMS = ((section/(60*step))*1000)
            
            if sectionInMS > durationT_Wave then
                break
            end
        end
        wait(1/60)
        wholeBeatLength += 1/60*1000
    end
    print("T wave: "..((os.clock()-startTime)*1000).." ms")
    
    section = 0
    sectionInMS = 0
end

local function BreakBetweenBeats()
    local startTime = os.clock()
    while wholeBeatLength < heartBPM do
        for i = 1, lineAccuracy do
            DrawLine()

            linePosX += step/lineAccuracy
            section += step/lineAccuracy
            sectionInMS = ((section/(60*step))*1000)
        end
        wait(1/60)
        wholeBeatLength += 1/60*1000
    end
    print("Pause: "..((os.clock()-startTime)*1000).." ms")
    
    section = 0
    sectionInMS = 0
    wholeBeatLength = 0
end

while true do
    DrawP_Wave()
    DrawQRS_Complex()
    DrawT_Wave()
    BreakBetweenBeats()
end
print("ECG session has ended.")
2

There are 2 best solutions below

2
kexrna On

just use task.wait() why do you even overthink it

2
Kylaaa On

So here's the thing about frame rates, there is never a guarantee that you'll get a consistent frame length. The engine has stuff it has to do every frame, and it allows your scripts to run for a set amount of time before they must yield back to the engine so it can keep doing stuff. task.wait() and wait() yield your script and effectively pause the execution for 1 frame, but the amount of time it takes the engine to resume your script cannot be guaranteed.

Here's a breakdown of a few of the many different methods of pausing your script for 1 frame, and the distribution of the length of those pauses.

Methods RunService
PreSimulation
RunService
Heartbeat
RunService
RenderStepped
wait() wait(1/60)
minimum (seconds) 0.0125 0.0110 0.0127 0.0308 0.0312
maximum (seconds) 0.0667 0.5910 0.7072 0.0506 0.0493
median (seconds) 0.0167 0.0169 0.0166 0.0333 0.0333
std dev (seconds) 0.0080 0.0474 0.0565 0.0019 0.0019

And an example distribution of those frame-rates would look like this : Histogram of Heartbeat Frame Lengths

We can see that wait() most consistently pauses frames, but it looks to be two frames before it resumes.

But here's the thing...

I think you've got a bit of an XY problem. The solution to your problem might not be finding a way to pause/yield for 1 frame, but rather to simply draw a value at the point based on the current timestamp. Imagine that your drawing cursor is moving at a constant rate, and every frame you simply place a point at its calculated position.

In your main script, set up the drawing segments :

local rs = game:GetService("RunService")

local drawPWave = require(script.p_wave)
local drawRestPhase = require(script.rest)
local drawQRSComplex = require(script.qrs_complex)
local drawTWave = require(script.t_wave)

local colGreen = Color3.fromRGB(25, 255, 25)

local ecgPhases = {
    {
        name = "P_Wave",
        getCursorPosition = drawPWave,
        duration = 0.300, --seconds
        color = colGreen,
    },
    {
        name = "PQ_Wave",
        getCursorPosition = drawRestPhase,
        duration = 0.120, --seconds
        color = colGreen,
    },
    {
        name = "QRS_Complex",
        getCursorPosition = drawQRSComplex,
        duration = 0.400, --seconds
        color = colGreen,
    },
    {
        name = "ST_Segment",
        getCursorPosition = drawRestPhase,
        duration = 0.250, --seconds
        color = colGreen,
    },
    {
        name = "T_Wave",
        getCursorPosition = drawTWave,
        duration = 0.400, --seconds
        color = colGreen,
    },
    {
        name = "BreakBetween",
        getCursorPosition = drawRestPhase,
        duration = 0.90, --seconds
        color = colGreen,
    },
}


local function DrawPoint(targetUI : GuiBase, x : number, y : number, color : Color3, pixelSize : number)
    local pixel = Instance.new("Frame", targetUI)
    pixel.Size = UDim2.new(0, pixelSize, 0, pixelSize)
    pixel.Position = UDim2.new(x, 0, y, 0)
    pixel.BackgroundColor3 = color
    pixel.BackgroundTransparency = 0
    pixel.BorderSizePixel = 0
    pixel.Name = "pixel"

    return pixel
end



-- targetUI : A UI element to render the ECG
local function drawECG(targetUI : GuiObject)
    local currentDrawState = 1
    local pixelSize = 2
    local pixels = {}
    
    local frameWidth = targetUI.AbsoluteSize.X
    local lineClearanceOffset = .3
    local lineClearing = false
    local speed = 50 -- pixels / second
    
    local currentSegmentTime = 0.0 -- seconds
    local x = 0
    
    local clearance = ((frameWidth - pixelSize) / frameWidth) - lineClearanceOffset
    
    return rs.Heartbeat:Connect(function(deltaTime)
        currentSegmentTime += deltaTime

        -- check if we should move onto the next segment
        local segmentDuration = ecgPhases[currentDrawState].duration
        if currentSegmentTime > segmentDuration then
            currentDrawState += 1
            if (currentDrawState > #ecgPhases) then
                currentDrawState = 1
            end
            currentSegmentTime = 0
        end

        -- calculate the value between [0, 1] for determining shape
        local percentComplete = (currentSegmentTime / segmentDuration)

        -- update the cursor position
        local inc = (deltaTime * speed) / frameWidth
        x = (x + inc) % 1.0
        if not lineClearing then
            lineClearing = (x >= clearance)
        else
            -- clear previous pixels
            pixels[1]:Destroy()
            table.remove(pixels, 1)
        end

        -- calculate y and sanitize nan calculations
        local y = ecgPhases[currentDrawState].getCursorPosition(percentComplete)
        y = if (y ~= y) then 0 else y
        -- convert and scale to screen coordinates
        y = (-0.5*y) + 0.5
        
        
        -- draw and store the current pixel
        local color = ecgPhases[currentDrawState].color
        local pixel = DrawPoint(targetUI, x, y, color, pixelSize)
        table.insert(pixels, pixel)
    end)
end


-- draw it to the UI
local leadFrame = script.Parent.blackBg.I_leadFrame
local drawingConnection = drawECG(leadFrame)
leadFrame.Destroying:Connect(function()
    drawingConnection:Disconnect()
end)

You'll notice that your drawing functions have been moved to ModuleScripts as children of this LocalScript. The point of this is to isolate the logic so that each module handles drawing values between [0, 1].

So in a ModuleScript child name p_wave add this :

local a = 0.125
local pi = math.pi

-- given a number between [0, 1]
-- returns a number between [-1, 1]
return function(percentComplete)
    -- sine is positive from [0, pi]
    local theta = pi * percentComplete -- radians
    return a * math.sin(theta)
end

In a ModuleScript child named qrs_complex, add this :

local pi = math.pi
local piOverTwo = pi / 2
local a =  0.5 -- amplitude
local b =  1.2 -- phase speed
local c = -0.5 -- phase offset, negative pushes wave ->
local d = -0.2 -- overall offset, negative moves wave down

-- when comparing floating point values
local function fuzzy_equals(left, right)
    return math.abs(left - right) < (1e-5)
end

-- given a number between [0, 1]
-- returns a number between [-1, 1]
return function(percentComplete)
    local theta = b * (pi * percentComplete) + c
    
    -- tangent is undefined where cos(theta) == 0
    -- ex) pi/2, 3pi/2
    if fuzzy_equals(theta, piOverTwo) then
        theta += 0.01
    end
    
    local spike = math.abs(a * math.tan(theta)) + d
    return math.min(spike, 1.0)
end

In a ModuleScript child named t_wave, add this :

local pi = math.pi
local A = 0.31

-- given a number between [0, 1]
-- returns a number between [-1, 1]
return function(percentComplete)
    
    local theta = pi * percentComplete
    return A*math.sin(theta)
end

And finally, in a ModuleScript child named rest, add this :

-- given a number between [0, 1]
-- returns a number between [-1, 1]
return function(percentComplete)
    return 0
end

The point of all of this separation is to reduce your piecewise function into easily debug-able pieces. The main drawing function configures how long each section should be drawn for, and handles the logic for piecing them together.

You'll likely need to reconstruct your equations for the different segments, I reduced them to simple equations for the sake of debugging.