diff --git a/CHANGELOG.md b/CHANGELOG.md index 56605a840de..230ae6d0c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Added `events.tick` to allow linking actions like animations to the renderloop. [#3948](https://github.com/MakieOrg/Makie.jl/pull/3948) - Added the `uv_transform` attribute for meshscatter, mesh, surface and image [#1406](https://github.com/MakieOrg/Makie.jl/pull/1406). - Added the ability to use textures with `meshscatter` in WGLMakie [#1406](https://github.com/MakieOrg/Makie.jl/pull/1406). - Don't remove underlying VideoStream file when doing save() [#3883](https://github.com/MakieOrg/Makie.jl/pull/3883). diff --git a/CairoMakie/test/runtests.jl b/CairoMakie/test/runtests.jl index cbff42c9098..0e7454826bc 100644 --- a/CairoMakie/test/runtests.jl +++ b/CairoMakie/test/runtests.jl @@ -249,3 +249,47 @@ end @test_throws ArgumentError save(filename, Figure(), pdf_version="foo") end + +@testset "Tick Events" begin + f, a, p = scatter(rand(10)); + @test events(f).tick[] == Makie.Tick() + + filename = "$(tempname()).png" + try + save(filename, f) + tick = events(f).tick[] + @test tick.state == Makie.OneTimeRenderTick + @test tick.count == 0 + @test tick.time == 0.0 + @test tick.delta_time == 0.0 + finally + rm(filename) + end + + filename = "$(tempname()).mp4" + try + tick_record = Makie.Tick[] + record(_ -> push!(tick_record, events(f).tick[]), f, filename, 1:10, framerate = 30) + dt = 1.0 / 30.0 + + for (i, tick) in enumerate(tick_record) + @test tick.state == Makie.OneTimeRenderTick + @test tick.count == i-1 + @test tick.time ≈ dt * (i-1) + @test tick.delta_time ≈ dt + end + finally + rm(filename) + end + + # test destruction of tick overwrite + f, a, p = scatter(rand(10)); + let + io = VideoStream(f) + @test events(f).tick[] == Makie.Tick(Makie.OneTimeRenderTick, 0, 0.0, 1.0 / io.options.framerate) + nothing + end + tick = Makie.Tick(Makie.UnknownTickState, 1, 1.0, 1.0) + events(f).tick[] = tick + @test events(f).tick[] == tick +end \ No newline at end of file diff --git a/GLMakie/src/display.jl b/GLMakie/src/display.jl index c8cb5a85064..0763cf6023b 100644 --- a/GLMakie/src/display.jl +++ b/GLMakie/src/display.jl @@ -10,7 +10,7 @@ function Base.display(screen::Screen, scene::Scene; connect=true) else @assert screen.root_scene === scene "internal error. Scene already displayed by screen but not as root scene" end - pollevents(screen) + pollevents(screen, Makie.BackendTick) return screen end diff --git a/GLMakie/src/drawing_primitives.jl b/GLMakie/src/drawing_primitives.jl index 60ea6959655..c0e6422ee08 100644 --- a/GLMakie/src/drawing_primitives.jl +++ b/GLMakie/src/drawing_primitives.jl @@ -24,7 +24,7 @@ function handle_lights(attr::Dict, screen::Screen, lights::Vector{Makie.Abstract attr[:light_colors] = Observable(sizehint!(RGBf[], MAX_LIGHTS)) attr[:light_parameters] = Observable(sizehint!(Float32[], MAX_PARAMS)) - on(screen.render_tick, priority = typemin(Int)) do _ + on(screen.render_tick, priority = -1000) do _ # derive number of lights from available lights. Both MAX_LIGHTS and # MAX_PARAMS are considered for this. n_lights = 0 @@ -231,7 +231,7 @@ const EXCLUDE_KEYS = Set([:transformation, :tickranges, :ticklabels, :raw, :SSAO function cached_robj!(robj_func, screen, scene, plot::AbstractPlot) # poll inside functions to make wait on compile less prominent - pollevents(screen) + pollevents(screen, Makie.BackendTick) robj = get!(screen.cache, objectid(plot)) do filtered = filter(plot.attributes) do (k, v) @@ -319,13 +319,13 @@ function Base.insert!(screen::Screen, scene::Scene, @nospecialize(x::Plot)) ShaderAbstractions.switch_context!(screen.glscreen) add_scene!(screen, scene) # poll inside functions to make wait on compile less prominent - pollevents(screen) + pollevents(screen, Makie.BackendTick) if isempty(x.plots) # if no plots inserted, this truly is an atomic draw_atomic(screen, scene, x) else foreach(x.plots) do x # poll inside functions to make wait on compile less prominent - pollevents(screen) + pollevents(screen, Makie.BackendTick) insert!(screen, scene, x) end end diff --git a/GLMakie/src/events.jl b/GLMakie/src/events.jl index 60a6b802637..365c7a702ec 100644 --- a/GLMakie/src/events.jl +++ b/GLMakie/src/events.jl @@ -186,7 +186,7 @@ struct MousePositionUpdater hasfocus::Observable{Bool} end -function (p::MousePositionUpdater)(::Nothing) +function (p::MousePositionUpdater)(::Makie.TickState) !p.hasfocus[] && return nw = to_native(p.screen) x, y = GLFW.GetCursorPos(nw) @@ -295,3 +295,15 @@ end function Makie.disconnect!(window::GLFW.Window, ::typeof(entered_window)) GLFW.SetCursorEnterCallback(window, nothing) end + +function Makie.frame_tick(scene::Scene, screen::Screen) + # Separating screen ticks from event ticks allows us to sanitize: + # Internal on-tick event updates happen first (mouseposition), + # consuming in event.tick listeners doesn't affect backend ticks, + # more control/consistent order + on(Makie.TickCallback(scene), scene, screen.render_tick, priority = typemin(Int)) +end +function Makie.disconnect!(screen::Screen, ::typeof(Makie.frame_tick)) + connections = filter(x -> x[2] isa Makie.TickCallback, screen.render_tick.listeners) + foreach(x -> off(screen.render_tick, x[2]), connections) +end \ No newline at end of file diff --git a/GLMakie/src/screen.jl b/GLMakie/src/screen.jl index 6e875341f84..bd01bbdedc3 100644 --- a/GLMakie/src/screen.jl +++ b/GLMakie/src/screen.jl @@ -176,7 +176,7 @@ mutable struct Screen{GLWindow} <: MakieScreen cache::Dict{UInt64, RenderObject} cache2plot::Dict{UInt32, AbstractPlot} framecache::Matrix{RGB{N0f8}} - render_tick::Observable{Nothing} # listeners must not Consume(true) + render_tick::Observable{Makie.TickState} # listeners must not Consume(true) window_open::Observable{Bool} scalefactor::Observable{Float32} @@ -210,7 +210,7 @@ mutable struct Screen{GLWindow} <: MakieScreen config, stop_renderloop, rendertask, BudgetedTimer(1.0 / 30.0), Observable(0f0), screen2scene, screens, renderlist, postprocessors, cache, cache2plot, - Matrix{RGB{N0f8}}(undef, s), Observable(nothing), + Matrix{RGB{N0f8}}(undef, s), Observable(Makie.UnknownTickState), Observable(true), Observable(0f0), nothing, reuse, true, false ) push!(ALL_SCREENS, screen) # track all created screens @@ -478,10 +478,10 @@ function Screen(scene::Scene, config::ScreenConfig, ::Makie.ImageStorageFormat; return screen end -function pollevents(screen::Screen) +function pollevents(screen::Screen, frame_state::Makie.TickState) ShaderAbstractions.switch_context!(screen.glscreen) GLFW.PollEvents() - notify(screen.render_tick) + screen.render_tick[] = frame_state return end @@ -770,7 +770,7 @@ function Makie.colorbuffer(screen::Screen, format::Makie.ImageStorageFormat = Ma ctex = screen.framebuffer.buffers[:color] # polling may change window size, when its bigger than monitor! # we still need to poll though, to get all the newest events! - pollevents(screen) + pollevents(screen, Makie.BackendTick) # keep current buffer size to allows larger-than-window renders render_frame(screen, resize_buffers=false) # let it render if screen.config.visible @@ -903,7 +903,7 @@ function set_framerate!(screen::Screen, fps=30) end function refreshwindowcb(screen, window) - screen.render_tick[] = nothing + screen.render_tick[] = Makie.BackendTick render_frame(screen) GLFW.SwapBuffers(window) return @@ -929,14 +929,13 @@ end scalechangeobs(screen) = scalefactor -> scalechangeobs(screen, scalefactor) -# TODO add render_tick event to scene events function vsynced_renderloop(screen) while isopen(screen) && !screen.stop_renderloop if screen.config.pause_renderloop - pollevents(screen); sleep(0.1) + pollevents(screen, Makie.PausedRenderTick); sleep(0.1) continue end - pollevents(screen) # GLFW poll + pollevents(screen, Makie.RegularRenderTick) # GLFW poll render_frame(screen) yield() GC.safepoint() @@ -947,10 +946,10 @@ end function fps_renderloop(screen::Screen) reset!(screen.timer, 1.0 / screen.config.framerate) while isopen(screen) && !screen.stop_renderloop - pollevents(screen) - - if !screen.config.pause_renderloop - pollevents(screen) # GLFW poll + if screen.config.pause_renderloop + pollevents(screen, Makie.PausedRenderTick) + else + pollevents(screen, Makie.RegularRenderTick) render_frame(screen) GLFW.SwapBuffers(to_native(screen)) end @@ -973,14 +972,18 @@ end # const time_record = sizehint!(Float64[], 100_000) function on_demand_renderloop(screen::Screen) + tick_state = Makie.UnknownTickState # last_time = time_ns() reset!(screen.timer, 1.0 / screen.config.framerate) while isopen(screen) && !screen.stop_renderloop - pollevents(screen) # GLFW poll + pollevents(screen, tick_state) # GLFW poll if !screen.config.pause_renderloop && requires_update(screen) + tick_state = Makie.RegularRenderTick render_frame(screen) GLFW.SwapBuffers(to_native(screen)) + else + tick_state = ifelse(screen.config.pause_renderloop, Makie.PausedRenderTick, Makie.SkippedRenderTick) end GC.safepoint() diff --git a/GLMakie/test/runtests.jl b/GLMakie/test/runtests.jl index 98e30bc505c..4b20757772d 100644 --- a/GLMakie/test/runtests.jl +++ b/GLMakie/test/runtests.jl @@ -37,3 +37,87 @@ include("unit_tests.jl") GLMakie.closeall() GC.gc(true) # make sure no finalizers act up! end + +@testset "Tick Events" begin + function check_tick(tick, state, count) + @test tick.state == state + @test tick.count == count + @test tick.time > 1e-9 + @test tick.delta_time > 1e-9 + end + + f, a, p = scatter(rand(10)); + @test events(f).tick[] == Makie.Tick() + + filename = "$(tempname()).png" + try + save(filename, f) + tick = events(f).tick[] + @test tick.state == Makie.OneTimeRenderTick + @test tick.count == 0 + @test tick.time == 0.0 + @test tick.delta_time == 0.0 + finally + rm(filename) + end + + filename = "$(tempname()).mp4" + try + tick_record = Makie.Tick[] + record(_ -> push!(tick_record, events(f).tick[]), f, filename, 1:10, framerate = 30) + dt = 1.0 / 30.0 + + for (i, tick) in enumerate(tick_record) + @test tick.state == Makie.OneTimeRenderTick + @test tick.count == i-1 + @test tick.time ≈ dt * (i-1) + @test tick.delta_time ≈ dt + end + finally + rm(filename) + end + + # test destruction of tick overwrite + f, a, p = scatter(rand(10)); + let + io = VideoStream(f) + @test events(f).tick[] == Makie.Tick(Makie.OneTimeRenderTick, 0, 0.0, 1.0 / io.options.framerate) + nothing + end + tick = Makie.Tick(Makie.UnknownTickState, 1, 1.0, 1.0) + events(f).tick[] = tick + @test events(f).tick[] == tick + + + f, a, p = scatter(rand(10)); + tick_record = Makie.Tick[] + on(t -> push!(tick_record, t), events(f).tick) + screen = GLMakie.Screen(render_on_demand = true, framerate = 30.0, pause_rendering = false, visible = false) + display(screen, f.scene) + sleep(0.15) + GLMakie.pause_renderloop!(screen) + sleep(0.1) + GLMakie.closeall() + + # Why does it start with a skipped tick? + i = 1 + while tick_record[i].state == Makie.SkippedRenderTick + check_tick(tick_record[1], Makie.SkippedRenderTick, i) + i += 1 + end + + check_tick(tick_record[i], Makie.RegularRenderTick, i) + i += 1 + + while tick_record[i].state == Makie.SkippedRenderTick + check_tick(tick_record[i], Makie.SkippedRenderTick, i) + i += 1 + end + + while (i <= length(tick_record)) && (tick_record[i].state == Makie.PausedRenderTick) + check_tick(tick_record[i], Makie.PausedRenderTick, i) + i += 1 + end + + @test i == length(tick_record)+1 +end \ No newline at end of file diff --git a/ReferenceTests/src/tests/examples3d.jl b/ReferenceTests/src/tests/examples3d.jl index 6c3caf5bbae..6541cda0d8e 100644 --- a/ReferenceTests/src/tests/examples3d.jl +++ b/ReferenceTests/src/tests/examples3d.jl @@ -424,12 +424,13 @@ end @reference_test "Normals of a Cat" begin x = loadasset("cat.obj") - mesh(x, color=:black) + f, a, p = mesh(x, color=:black) pos = map(decompose(Point3f, x), GeometryBasics.normals(x)) do p, n p => p .+ Point(normalize(n) .* 0.05f0) end linesegments!(pos, color=:blue) - current_figure() + Makie.update_state_before_display!(f) + f end @reference_test "Sphere Mesh" begin diff --git a/ReferenceTests/src/tests/updating.jl b/ReferenceTests/src/tests/updating.jl index 3c362fbea7f..bf177009373 100644 --- a/ReferenceTests/src/tests/updating.jl +++ b/ReferenceTests/src/tests/updating.jl @@ -173,3 +173,22 @@ end ax.title = "identity" Makie.step!(st) end + + +@reference_test "event ticks in record" begin + # Checks whether record calculates and triggers event.tick by drawing a + # Point at y = 1 for each frame where it does. The animation is irrelevant + # here, so we can just check the final image. + # The first point maybe at 0 depending on when the backend sets up it's + # reference time + ps = Observable(Point2f[]) + f, a, p = scatter(ps) + xlims!(a, 0, 61) + ylims!(a, -0.1, 1.1) + Record(f, 1:60, framerate = 30) do i + push!(ps.val, Point2f(i, f.scene.events.tick[].delta_time > 1e-6)) + notify(ps) + f.scene.events.tick[] = Makie.Tick(Makie.UnknownTickState, 0, 0.0, 0.0) + end + f +end \ No newline at end of file diff --git a/WGLMakie/src/display.jl b/WGLMakie/src/display.jl index 01c7cf1629a..2493966d030 100644 --- a/WGLMakie/src/display.jl +++ b/WGLMakie/src/display.jl @@ -53,8 +53,16 @@ mutable struct Screen <: Makie.MakieScreen displayed_scenes::Set{String} config::ScreenConfig canvas::Union{Nothing,Bonito.HTMLElement} + tick_clock::Makie.BudgetedTimer function Screen(scene::Union{Nothing,Scene}, config::ScreenConfig) - return new(Channel{Bool}(1), nothing, scene, Set{String}(), config, nothing) + timer = Makie.BudgetedTimer(1.0 / 30.0) + screen = new(Channel{Bool}(1), nothing, scene, Set{String}(), config, nothing, timer) + + finalizer(screen) do screen + close(screen.tick_clock) + end + + return screen end end diff --git a/WGLMakie/src/events.jl b/WGLMakie/src/events.jl index 20792591177..51ba85d56d4 100644 --- a/WGLMakie/src/events.jl +++ b/WGLMakie/src/events.jl @@ -52,7 +52,7 @@ function code_to_keyboard(code::String) end end -function connect_scene_events!(scene::Scene, comm::Observable) +function connect_scene_events!(screen::Screen, scene::Scene, comm::Observable) e = events(scene) on(comm) do msg @async try @@ -117,5 +117,17 @@ function connect_scene_events!(scene::Scene, comm::Observable) end return end + + tick_callback = Makie.TickCallback(e.tick) + Makie.start!(screen.tick_clock) do timer + if isopen(screen) + tick_callback(Makie.RegularRenderTick) + # @info "tick $(e.tick[].count) $(e.tick[].delta_time)" + else + stop!(timer) + end + return + end + return end diff --git a/WGLMakie/src/three_plot.jl b/WGLMakie/src/three_plot.jl index de0cd8af95b..aeb93ddc914 100644 --- a/WGLMakie/src/three_plot.jl +++ b/WGLMakie/src/three_plot.jl @@ -69,6 +69,6 @@ function three_display(screen::Screen, session::Session, scene::Scene) on(session, done_init) do val window_open[] = true end - connect_scene_events!(scene, comm) + connect_scene_events!(screen, scene, comm) return wrapper, done_init end diff --git a/WGLMakie/test/runtests.jl b/WGLMakie/test/runtests.jl index f7e4bd786b6..f6630c10dcb 100644 --- a/WGLMakie/test/runtests.jl +++ b/WGLMakie/test/runtests.jl @@ -70,3 +70,62 @@ end # we used Retain for global_obs, so it should stay as long as root session is open @test keys(js_objects) == Set([WGLMakie.TEXTURE_ATLAS.id]) end + +@testset "Tick Events" begin + function check_tick(tick, state, count) + @test tick.state == state + @test tick.count == count + @test tick.time > 1e-9 + @test tick.delta_time > 1e-9 + end + + f, a, p = scatter(rand(10)); + @test events(f).tick[] == Makie.Tick() + + filename = "$(tempname()).png" + try + tick_record = Makie.Tick[] + on(tick -> push!(tick_record, tick), events(f).tick) + save(filename, f) + idx = findfirst(tick -> tick.state == Makie.OneTimeRenderTick, tick_record) + tick = tick_record[idx] + @test tick.state == Makie.OneTimeRenderTick + @test tick.count == 0 + @test tick.time == 0.0 + @test tick.delta_time == 0.0 + finally + close(f.scene.current_screens[1]) + rm(filename) + end + + + f, a, p = scatter(rand(10)); + filename = "$(tempname()).mp4" + try + tick_record = Makie.Tick[] + record(_ -> push!(tick_record, events(f).tick[]), f, filename, 1:10, framerate = 30) + dt = 1.0 / 30.0 + + for (i, tick) in enumerate(tick_record) + @test tick.state == Makie.OneTimeRenderTick + @test tick.count == i-1 + @test tick.time ≈ dt * (i-1) + @test tick.delta_time ≈ dt + end + finally + rm(filename) + end + + # test destruction of tick overwrite + f, a, p = scatter(rand(10)); + let + io = VideoStream(f) + @test events(f).tick[] == Makie.Tick(Makie.OneTimeRenderTick, 0, 0.0, 1.0 / io.options.framerate) + nothing + end + tick = Makie.Tick(Makie.UnknownTickState, 1, 1.0, 1.0) + events(f).tick[] = tick + @test events(f).tick[] == tick + + # TODO: test normal rendering +end \ No newline at end of file diff --git a/docs/src/explanations/events.md b/docs/src/explanations/events.md index b58da505e39..3969a19f62e 100644 --- a/docs/src/explanations/events.md +++ b/docs/src/explanations/events.md @@ -58,6 +58,7 @@ Events from the backend are stored in Observables within the `Events` struct. Yo - `keyboardstate::Observable{Keyboard.Button}`: Contains all currently pressed keys. - `unicode_input::Observable{Char}`: Contains the most recently typed character. - `dropped_files::Observable{Vector{String}}`: Contains a list of filepaths to a collection files dragged into the window. +- `tick::Observable{Makie.Tick}`: Contains the most recent `Makie.Tick`. A `tick` is produced for every frame rendered, i.e. at regular intervals for interactive figures, when a image is saved or when `record()` is used. ## Mouse Interaction @@ -397,3 +398,46 @@ record(scene, "test.mp4"; framerate = fps) do io end end ``` + +## Tick Events + +Tick events are produced by the renderloop in GLMakie and WGLMakie, as well as `Makie.save` and `Makie.record` for all backends. +They allow you to synchronize tasks such as animations with rendering. +A Tick contains the following information: + +- `state::Makie.TickState`: Describes the situation in which the tick was produced. These include: + - `Makie.UnknownTickState`: A catch-all for uncategorized ticks. Currently only used for initialization of the tick event. + - `Makie.PausedRenderTick`: A tick originating from a paused renderloop in GLMakie. (This refers to the last attempt to draw a frame.) + - `Makie.SkippedRenderTick`: A tick originating from a `render_on_demand = true` renderloop in GLMakie where the last frame has been reused. (This happens when nothing about the displayed frame has changed. For animation you may consider this a regular render tick.) + - `Makie.RegularRenderTick`: A tick from a renderloop where the last frame has been redrawn. + - `Makie.OneTimeRenderTick`: A tick produced before generating an image for `Makie.save` and `Makie.record`. +- `count::Int64`: Number of ticks produced since the first. During `record` this is relative to the first recorded frame. +- `time::Float64`: The time that has passed since the first tick in seconds. During `record` this is relative to the first recorded frame and increments based on the `framerate` set in record. +- `delta_time::Float64`: The time that has passed since the last tick in seconds. During `record` this is `1 / framerate`. + +For an animation you will generally not need to worry about tick state. +You can simply update the relevant data as needed. +```julia +on(events(fig).tick) do tick + # For a simulation you may want to use delta times for updates: + position[] = position[] + tick.delta_time * velocity + + # For a solved system you may want to use the total time to compute the current state: + position[] = trajectory(tick.time) + + # For a data record you may want to use count to move to the next data point(s) + position[] = position_record[mod1(tick.count, end)] +end +``` + +For an interactive figure this will produce an animation synchronized with real time. +Within `record` the tick times match up the set `framerate` such that the animation in the produced video matches up with real time. + +Note that the underlying `VideoStream` filters tick events other than `state = OneTimeRenderTick`. +This is done to prevent jumping (wrong count, time) or acceleration (extra ticks) of animations in videos due to extra ticks. +(This is specifically an issue with WGLMakie, as it still runs a normal renderloop while recording.) +Ticks will no longer be filtered once the `VideoStream` object is deleted or the video is saved. +The behavior can also be turned off by setting `filter_ticks = false`. + +For reference, a `tick` generally happens after other events have been processed and before the next frame will be drawn. +The exception is WGLMakie which runs an independent timer to avoid excessive message passing between Javascript and Julia. \ No newline at end of file diff --git a/src/display.jl b/src/display.jl index c7cf1b3f5b7..fd1d2a715e8 100644 --- a/src/display.jl +++ b/src/display.jl @@ -341,6 +341,7 @@ function FileIO.save( config = Dict{Symbol, Any}(screen_config) get!(config, :visible, visible) screen = getscreen(backend, scene, config, io, mime) + events(fig).tick[] = Tick(OneTimeRenderTick, 0, 0.0, 0.0) backend_show(screen, io, mime, scene) end catch e diff --git a/src/ffmpeg-util.jl b/src/ffmpeg-util.jl index 322c09d4406..b7e7b1bb55c 100644 --- a/src/ffmpeg-util.jl +++ b/src/ffmpeg-util.jl @@ -177,11 +177,45 @@ function to_ffmpeg_cmd(vso::VideoStreamOptions, xdim::Integer=0, ydim::Integer=0 return `$(ffmpeg_prefix) $(ffmpeg_options)` end +mutable struct TickController + tick::Observable{Tick} + frame_counter::Int + frame_time::Float64 + filter_ticks::Bool + filter_callback::Observables.ObserverFunction +end + +function TickController(figlike, frametime, filter = true) + tick = events(figlike).tick + cb = if filter + on(tick -> Consume(tick.state != OneTimeRenderTick), tick, priority = typemax(Int)) + else + on(tick -> nothing, tick, priority = typemax(Int)) + end + controller = TickController(tick, 0, frametime, filter, cb) + finalizer(stop!, controller) + next_tick!(controller) + return controller +end + +function next_tick!(controller::TickController) + controller.tick[] = Tick( + OneTimeRenderTick, + controller.frame_counter, + controller.frame_counter * controller.frame_time, + controller.frame_time + ) + controller.frame_counter += 1 + return +end + +stop!(controller::TickController) = off(controller.filter_callback) mutable struct VideoStream io::Base.PipeEndpoint process::Base.Process screen::MakieScreen + tick_controller::TickController buffer::Matrix{RGB{N0f8}} path::String options::VideoStreamOptions @@ -190,7 +224,7 @@ end """ VideoStream(fig::FigureLike; format="mp4", framerate=24, compression=nothing, profile=nothing, pixel_format=nothing, loop=nothing, - loglevel="quiet", visible=false, connect=false, backend=current_backend(), + loglevel="quiet", visible=false, connect=false, filter_ticks=true, backend=current_backend(), screen_config...) Returns a `VideoStream` which can pipe new frames into the ffmpeg process with few allocations via [`recordframe!(stream)`](@ref). @@ -208,11 +242,15 @@ $(Base.doc(VideoStreamOptions)) * `visible=false`: make window visible or not * `connect=false`: connect window events or not * `screen_config...`: See `?Backend.Screen` or `Base.doc(Backend.Screen)` for applicable options that can be passed and forwarded to the backend. + +## Other + +* `filter_ticks`: When true, tick events other than `tick.state = Makie.OneTimeRenderTick` are removed until `save()` is called or the VideoStream object gets deleted. """ function VideoStream(fig::FigureLike; format="mp4", framerate=24, compression=nothing, profile=nothing, pixel_format=nothing, loop=nothing, - loglevel="quiet", visible=false, update=true, backend=current_backend(), - screen_config...) + loglevel="quiet", visible=false, update=true, filter_ticks=true, + backend=current_backend(), screen_config...) dir = mktempdir() path = joinpath(dir, "$(gensym(:video)).$(format)") @@ -229,9 +267,11 @@ function VideoStream(fig::FigureLike; vso = VideoStreamOptions(format, framerate, compression, profile, pixel_format, loop, loglevel, "pipe:0", true) cmd = to_ffmpeg_cmd(vso, xdim, ydim) process = open(`$(FFMPEG_jll.ffmpeg()) $cmd $path`, "w") - result = VideoStream(process.in, process, screen, buffer, abspath(path), vso) + tick_controller = TickController(fig, 1.0 / vso.framerate, filter_ticks) + result = VideoStream(process.in, process, screen, tick_controller, buffer, abspath(path), vso) finalizer(result) do x @async rm(x.path; force=true) + stop!(x.tick_controller) end return result end @@ -248,6 +288,7 @@ function recordframe!(io::VideoStream) xdim, ydim = size(glnative) copy!(view(io.buffer, 1:xdim, 1:ydim), glnative) write(io.io, io.buffer) + next_tick!(io.tick_controller) return end diff --git a/src/interaction/events.jl b/src/interaction/events.jl index 1e0db419b6b..20e10ce29a6 100644 --- a/src/interaction/events.jl +++ b/src/interaction/events.jl @@ -10,6 +10,7 @@ unicode_input(scene, native_window) = not_implemented_for(native_window) dropped_files(scene, native_window) = not_implemented_for(native_window) hasfocus(scene, native_window) = not_implemented_for(native_window) entered_window(scene, native_window) = not_implemented_for(native_window) +frame_tick(scene, native_window) = not_implemented_for(native_window) function connect_screen(scene::Scene, screen) @@ -27,6 +28,7 @@ function connect_screen(scene::Scene, screen) dropped_files(scene, screen) hasfocus(scene, screen) entered_window(scene, screen) + frame_tick(scene, screen) return end @@ -49,12 +51,13 @@ function disconnect_screen(scene::Scene, screen) disconnect!(screen, dropped_files) disconnect!(screen, hasfocus) disconnect!(screen, entered_window) + disconnect!(screen, frame_tick) end return end """ -Picks a mouse position. Implemented by the backend. +Picks a mouse position. Implemented by the backend. """ function pick end @@ -65,11 +68,35 @@ end """ onpick(func, plot) -Calls `func` if one clicks on `plot`. Implemented by the backend. +Calls `func` if one clicks on `plot`. Implemented by the backend. """ function onpick end +mutable struct TickCallback + event::Observable{Makie.Tick} + start_time::UInt64 + last_time::UInt64 + TickCallback(tick::Observable{Makie.Tick}) = new(tick, time_ns(), time_ns()) +end +TickCallback(scene::SceneLike) = TickCallback(events(scene).tick) + +function (cb::TickCallback)(x::Makie.TickState) + if x > Makie.UnknownTickState # not backend or Unknown + cb.last_time = Makie.next_tick!(cb.event, x, cb.start_time, cb.last_time) + end + return nothing +end + +function next_tick!(tick::Observable{Tick}, state::TickState, start_time::UInt64, last_time::UInt64) + t = time_ns() + since_start = 1e-9 * (t - start_time) + delta_time = 1e-9 * (t - last_time) + tick[] = Tick(state, tick[].count + 1, since_start, delta_time) + return t +end + + ################################################################################ ### ispressed logic ################################################################################ diff --git a/src/types.jl b/src/types.jl index 11248c28fec..984bcaffdac 100644 --- a/src/types.jl +++ b/src/types.jl @@ -16,6 +16,43 @@ end include("interaction/iodevices.jl") +""" + enum TickState + +Identifies the source of a tick: +- `BackendTick`: A tick used for backend purposes which is not present in `event.tick`. +- `UnknownTickState`: A tick from an uncategorized source (e.g. intialization of Events). +- `PausedRenderTick`: A tick from a paused renderloop. +- `SkippedRenderTick`: A tick from a running renderloop where the previous image was reused. +- `RegularRenderTick`: A tick from a running renderloop where a new image was produced. +- `OneTimeRenderTick`: A tick from a call to `colorbuffer`, i.e. an image request from `save` or `record`. +""" +@enum TickState begin + BackendTick + UnknownTickState # GLMakie only allows states > UnknownTickState + PausedRenderTick + SkippedRenderTick + RegularRenderTick + OneTimeRenderTick +end + +""" + struct TickState + +Contains information for tick events: +- `state::TickState`: identifies what caused the tick (see Makie.TickState) +- `count::Int64`: number of ticks produced since the start of rendering (display or record) +- `time::Float64`: time that has passed since the first tick in seconds +- `delta_time`: time that has passed since the last tick in seconds +""" +struct Tick + state::TickState # flag for the type of tick event + count::Int64 # number of ticks since start + time::Float64 # time since scene initialization + delta_time::Float64 # time since last tick +end +Tick() = Tick(UnknownTickState, 0, 0.0, 0.0) + """ This struct provides accessible `Observable`s to monitor the events @@ -103,6 +140,17 @@ struct Events Whether the mouse is inside the window or not. """ entered_window::Observable{Bool} + + """ + A `tick` is triggered whenever a new frame is requested, i.e. during normal + rendering (even if the renderloop is paused) or when an image is produced + for `save` or `record`. A Tick contains: + - `state` which identifies what caused the tick (see Makie.TickState) + - `count` which increments with every tick + - `time` which is the total time since the screen has been created + - `delta_time` which is the time since the last frame + """ + tick::Observable{Tick} end function Base.show(io::IO, events::Events) @@ -132,6 +180,7 @@ function Events() Observable(String[]), Observable(false), Observable(false), + Observable(Tick()) ) connect_states!(events) diff --git a/src/utilities/timing.jl b/src/utilities/timing.jl index 4684ed381c1..df06462f44c 100644 --- a/src/utilities/timing.jl +++ b/src/utilities/timing.jl @@ -38,7 +38,7 @@ function BudgetedTimer(callback, delta_time::AbstractFloat, start = true; min_sl timer = BudgetedTimer(callback, delta_time, true, nothing, min_sleep) if start timer.task = @async while timer.running - timer.callback() + timer.callback(timer) sleep(timer) end end @@ -50,7 +50,7 @@ function start!(timer::BudgetedTimer) timer.last_time = time_ns() timer.running = true timer.task = @async while timer.running - timer.callback() + timer.callback(timer) sleep(timer) end return @@ -61,11 +61,20 @@ function start!(callback, timer::BudgetedTimer) return start!(timer) end +function set_callback!(callback, timer::BudgetedTimer) + timer.callback = callback +end + function stop!(timer::BudgetedTimer) timer.running = false return end +function Base.close(timer::BudgetedTimer) + timer.callback = identity + stop!(timer) +end + function reset!(timer::BudgetedTimer, delta_time = timer.target_delta_time) timer.target_delta_time = delta_time timer.budget = 0.0