Makie does not update the plot during an animation

Working with lots of geometry objects, I wrote some recipes to plot such objects using low-level commands like lines! and scatter!
I have a master-recipe that combines all of those so that my call sequence remains simple. Now I am trying to animate such complex plots using lifts and Observables

My call sequence looks like

time = Observable(0.0)
constantargument = 1
varargument = @lift(fun($time))
f,ax,p = mymasterrecipeplot!(constantargument,varargument)
for t in 0:100
    time[] = t
    sleep(0.5)
end

At the beginning, no variables defined in the recipes was updated when upon the time[] call.
So I wrapped all my underlying recipes such that any of them was declared lifting the arguments. For instance

Makie.@recipe(MyMasterRecipePlot,arg1,arg2) do scene
    # Recipe declarations
end

function Makie.plot!(plt::MyMasterRecipePlot{<:Tuple{<arg1 type>,<arg2 type>})
    (;arg1,arg2) = plt
    lift(arg1,arg2) do arg1, arg2
        # computation and other calls here
        return plt
    end
end

At that point I can see that the values passed upon time update are modified. However, these new values are not reflected in the plotted figure. I tried to display(f) in the loop but that does not change it.
However, if I close manually the figure during the update loop, it reopens at the next iteration, showing all consecutive plots that should have been plotted during the previous loop iterations on top of each other in the same figure.

Could someone tell me what I am doing wrong ?

The plotting calls in your recipe should only be done once, not on every observable update. But the input observables to these inner plotting calls should be lifted from the recipe input observables. Whenever these are updated, the inner plots receive updated inputs and visually update. I would recommend to have a look in the recipes folder of Makie’s source, to see how we do it there. Probably easier to learn by example.

As far as I understand what you mean, It seems to me it is what I do in the call sequence in the first codeblock, isn"t it ?

  • Call de main recipe once, then update the observable
  • Have all arguments in the underlying recipes be lifted before being used in each recipe ?

I had a look at the Makie source, as you advised, what I found is the following

  • In hvlines, observables seem to be called via onany
  • In scatterlines, the arguments of the calling recipe are passed to the underlying recipes directly, without lift, and some map! is used too…
  • In series, attributes of the plot are extracted with @extract but I’m guessing this is for backwards,compatibility (I may have overlooked that). Some lift calls are used, and @lift calls as well.

All in all, this shows that there are multiple related ways to achieve an animation which I (somehow, with a broken outcome) managed to do, but what I can’t figure is what prevents the figure from updating, since the underlying data is correctly updated. It’s like some kind of flush operation does not happen and trigger the replot in the GLMakie window.

Indeed, I also have the same issue. Saving the animation and doing the animation is fine, but the live update in the window is not longer there.

I have to say that reproducing a simple example from the Makie website works in my case (even the live updating) but not something inside a custom recipe.

That sounds like a bug then…

This works because the arguments we pass on are already Observables.
The use of map! is just an inplace version of lift. I think you could have also written

real_markercolor = Observable{Union{Vector{RGBAf}, RGBAf}}()
onany(p.color, p.markercolor) do col, mcol
    if mcol === automatic
        real_marker[] = to_color(col)
    else
        real_marker[] = to_color(mcol)
    end
end

I guess the reason for not using lift here is that otherwise you would have to convert your result to Union{Vector{RGBAf}, RGBAf} before returning, which looks akward…

@extract plot (curves, labels, linewidth, color, solid_color)

is just short for

curves = plot.curves
linewidth = plot.linewidth
color = plot.color
solid_color = plot.solid_color

so nothing special. Again, the key is that curves, linewidth, color, solid_color are Observables and passed on as such.

lift is usually used when there’s a simple many to one relationship of observables where the output type is always the same. If it’s not, the second or later invocation of the function can error as the observable will get the type of the first result with lift.

So one can avoid this by defining the observable with the desired type beforehand, to then call map! on it.

And onany is usually used when multiple observables need to be updated and the order of updates has to be carefully orchestrated to avoid desynchronization or double updates.

Ok, I think I managed to make a reproducible example based on my code.

First, a slightly modified example in 3D based on an example in the Makie website

This was tested on windows 10, GLMakie v0.7.3

using GLMakie, GeometryBasics

time = Observable(0.0)
# 3D version of Makie animation example (works fine, even live update)
xs = range(0, 7, length=40)
ys_1 = @lift(sin.(xs .- $time))
ys_2 = @lift(cos.(xs .- $time) .+ 3)
z_s = @lift(sin.(xs .+ 2 * $time))

fig = lines(xs, ys_1, z_s, color=:blue, linewidth=4)
scatter!(xs, ys_2, z_s, color=:red, markersize=15)
fig
framerate = 30
timestamps = range(0, 2, step=1 / framerate)

@lazarusA maybe try this one to see if you manage to get a live update

Now an example with recipes

# Setup a data container of geometry objects
struct MyGeometry
    points::Vector{Point3f}
end

# Declare a sub-recipe
Makie.@recipe(NodePlot, points) do scene
    Attributes(
        color=:black
    )
end
function Makie.plot!(plt::NodePlot{<:Tuple{AbstractVector{<:AbstractPoint}}})
    # Unpack all relevant arguments
    (; points) = plt
    lift(points) do points
        # Just to see if points are updated from the outside
        @info first(points)
        meshscatter!(plt, points)
        return plt
    end
end

# Declare the master recipe
Makie.@recipe(MyGeometryPlot, geometry) do scene
    Attributes(
        color=:black
    )
end
function Makie.plot!(plt::MyGeometryPlot{MyGeometry})
    # Unpack all relevant arguments
    (; geometry) = plt
    lift(geometry) do geometry
        points = geometry.points
        !isempty(points) && nodeplot!(plt, points)
        return plt
    end
end

# Declare the actual geometry
mypoints = rand(Point3f,10)
geometry = @lift(MyGeometry(mypoints .+ Point3f($time)))
# Plot the geometry
mygeometryplot(geometry)

# Try and update the geometry
for t in timestamps
    time[] = t
    sleep(0.1)
end

On my machine, the @info call shows that the points are modified by the observable update, but that change is not reflected on the screen. Can anyone reproduce this ? And / or is it a misuse of the lift calls ?

As I tried to explain before, you’re not supposed to modify plot objects in lift closures like this

lift(geometry) do geometry
        points = geometry.points
        !isempty(points) && nodeplot!(plt, points)
        return plt
    end

nodeplot! should be called just once with points = lift(g -> g.points, geometry) as input

That dit it thanks !

Here is the corrected example

using GLMakie, GeometryBasics

time = Observable(0.0)
framerate = 30
timestamps = range(0, 2, step=1 / framerate)

# Setup a data container of geometry objects
struct MyGeometry
    points::Vector{Point3f}
end

# Declare a sub-recipe
Makie.@recipe(NodePlot, points) do scene
    Attributes(
        color=:black
    )
end
function Makie.plot!(plt::NodePlot{<:Tuple{AbstractVector{<:AbstractPoint}}})
    # Unpack all relevant arguments
    (; points) = plt
        meshscatter!(plt, points)
        return plt
    end
end

# Declare the master recipe
Makie.@recipe(MyGeometryPlot, geometry) do scene
    Attributes(
        color=:black
    )
end
function Makie.plot!(plt::MyGeometryPlot{<:Tuple{MyGeometry}})
    # Unpack all relevant arguments
    (; geometry) = plt
    points = lift(g -> g.points, geometry)
    nodeplot!(plt, points)
    return plt
end

# Declare the actual geometry
mypoints = rand(Point3f, 10)
geometry = @lift(MyGeometry(mypoints .+ Point3f($time)))
# Plot the geometry
f, ax, p = mygeometryplot(geometry)

# Try and update the geometry
for t in timestamps
    time[] = t
    sleep(0.05)
end

Jules, a side question, if I may. When dealing with large lift blocks I am returning multiple values, however, the lift returns an Observable{Tuple{...}} and not a Tuple{Observable{...},...} how do you unpack this kind of structure ?

Multiple values are the achilles heel of the observable workflow. The problem is this:

Here, A triggers B and C, and both trigger D. In order to avoid a double update of D, A should first do B.val = new_value, then C[] = new_value.

     β”Œβ”€β”€β”€β”€β”€β”
   β”Œβ”€β”€  A  β”œβ”€β”
   β”‚ β””β”€β”€β”€β”€β”€β”˜ β”‚
   β”‚         β”‚
   β”‚         β”‚
β”Œβ”€β”€β–Όβ”€β”€β”   β”Œβ”€β”€β–Όβ”€β”€β”
β”‚  B  β”‚   β”‚  C  β”‚
β””β”€β”€β”¬β”€β”€β”˜   β””β”€β”€β”¬β”€β”€β”˜
   β”‚         β”‚
   β”‚         β”‚
   β”‚ β”Œβ”€β”€β”€β”€β”€β” β”‚
   β””β–Ίβ”‚  D  β—„β”€β”˜
     β””β”€β”€β”€β”€β”€β”˜

Now B has another listener, E. We can’t do B.val = ... anymore because E won’t be updated. But we can still do C.val = ... and B[] = ....

          β”Œβ”€β”€β”€β”€β”€β”
        β”Œβ”€β”€  A  β”œβ”€β”
        β”‚ β””β”€β”€β”€β”€β”€β”˜ β”‚
        β”‚         β”‚
        β”‚         β”‚
     β”Œβ”€β”€β–Όβ”€β”€β”   β”Œβ”€β”€β–Όβ”€β”€β”
   β”Œβ”€β”€  B  β”‚   β”‚  C  β”‚
   β”‚ β””β”€β”€β”¬β”€β”€β”˜   β””β”€β”€β”¬β”€β”€β”˜
   β”‚    β”‚         β”‚
   β”‚    β”‚         β”‚
β”Œβ”€β”€β–Όβ”€β”€β” β”‚ β”Œβ”€β”€β”€β”€β”€β” β”‚
β”‚  E  β”‚ β””β–Ίβ”‚  D  β—„β”€β”˜
β””β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”˜

But if C also has a second listener, F this is impossible:

          β”Œβ”€β”€β”€β”€β”€β”
        β”Œβ”€β”€  A  β”œβ”€β”
        β”‚ β””β”€β”€β”€β”€β”€β”˜ β”‚
        β”‚         β”‚
        β”‚         β”‚
     β”Œβ”€β”€β–Όβ”€β”€β”   β”Œβ”€β”€β–Όβ”€β”€β”
   β”Œβ”€β”€  B  β”‚   β”‚  C  β”œβ”€β”
   β”‚ β””β”€β”€β”¬β”€β”€β”˜   β””β”€β”€β”¬β”€β”€β”˜ β”‚
   β”‚    β”‚         β”‚    β”‚
   β”‚    β”‚         β”‚    β”‚
β”Œβ”€β”€β–Όβ”€β”€β” β”‚ β”Œβ”€β”€β”€β”€β”€β” β”‚ β”Œβ”€β”€β–Όβ”€β”€β”
β”‚  E  β”‚ β””β–Ίβ”‚  D  β—„β”€β”˜ β”‚  F  β”‚
β””β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”˜

You cannot avoid updating both B and C if you want to trigger their listeners E and F, which means D is updated twice. But if B and C store arrays whose lengths change with the update of A, it can be that the first update of D, after B is triggered, errors because B and C have different lengths in that moment. In that case it’s impossible to update all observables correctly. We’re still trying to figure out ways in which we can make the updating process simpler, but it’s a thorny issue and observables are deeply ingrained in the whole codebase, so we must be careful not to break anything with a β€œfix”.

I say this because you asked what to do with a tuple resulting from lift. But this usually happens because one calculates multiple input arguments for an inner plot object from input observables. In that case, one has to define all the observables beforehand, and carefully update them inside an on or onany function with obs.val = ... and only once (if possible) with obs[] = ... so that one doesn’t trigger duplicate updates downstream. But as I showed, this is sometimes impossible. Here’s one example of such a thing: Makie.jl/contourf.jl at master Β· MakieOrg/Makie.jl Β· GitHub
The colors observable is not triggered, its content array is just mutated. This way the downstream poly will have access to the new colors when its input observable polys is triggered. This way polys and colors can always have the same length from the perspective of the poly plot. But this wouldn’t work if something internal to poly relied on the color observable actually triggering…

1 Like

Thanks for this detailed answer ! Indeed, finding a good compromise between automatic handling of updates and unwanted multiple updates is tricky !
To simplify this answer to its core, what I understand from it at least, one should use observables in a parsimonious manner right ? So as not to trigger too much updates ?

Yes, parsimonious is good. My heuristic is that it usually works to obs.val = .. update all the style attributes of a plot and only then trigger one of the positional arguments last, which usually makes everything redraw correctly. But if the internal logic is too complex, this doesn’t always work. I have some ideas about multi-observables that could alleviate some of these paint points, but those haven’t been fleshed out enough, yet.

2 Likes