We're happy to present you with one of the largest Makie releases we've made to date!
It was in the works for about a year, a year in which we've had our first Makie conference and saw the adoption of Makie among Julia users rise. One indicator for that is the citation count of our JOSS paper (thank you for acknowledging our work if it benefits you with your own!), which at the moment of writing is just shy of 100:
It's great to flip through all those papers and see how Makie is used in the wild!
Version 0.20 will enable users to make even better plots, as it contains many long-awaited features and fixes. So let's take a look at some of the most prominent additions and improvements.
CairoMakie has long had the ability to increase the resolution of saved images with the
px_per_unit
setting. However, GLMakie could only render at exactly the
resolution
set in a
Figure
or
Scene
. This caused several problems: Figures would look too small on high-dpi and too large on low-dpi screens. For inclusion of GLMakie figures in publications, users would have to compute the final resolution for the right dpi count manually, and then adjust all other sizes like fontsize, scatter markers, line widths, etc. to match.
Now, GLMakie supports
px_per_unit
which means that you can render high-resolution results with a simple
save("output.png", figure, px_per_unit = 5)
. Interactive windows can automatically adjust their
px_per_unit
value to the screen's scaling factor reported by the operating system. This way, Makie windows should always be appropriately sized, with legible, sharp text.
This effort was spearheaded by Justin Willmert in a heroic and completely unexpected first-contributor effort in #2544, many thanks to him!
With this improvement come a few changes in the API and default Makie theme. The
resolution
keyword is deprecated and now called
size
, reflecting that this is not a pixel size anymore, even for GLMakie. The default figure size is now
(600, 450)
instead of
(800, 600)
, with a fontsize of
14
instead of
16
device-independent pixels (10.5 instead of 12 pt). We do not have to make these sizes a compromise between high-dpi and low-dpi systems anymore, so we felt the default was too large for typical uses in documents or websites.
Here's a screenshot from a high-dpi Macbook from the previous Makie version, where you can see that, compared with other apps and system fonts, the window and fontsize used to be pretty small due to unawareness of screen scale factors, which meant that the 800x600 window ended up at a measly 400x300 logical pixels:
Now, the window has 600x450 logical pixels, backed by 1200x900 actual pixels, so text is sharp and much more readable.
Speaking of websites, if you were using Makie on high-dpi screens with web-technology based tools like Pluto, Jupyter notebooks or VSCode, you probably noticed that plots (except for svgs) would often look blurry. Increasing
resolution
in GLMakie or
px_per_unit
in CairoMakie didn't make the images sharper, just bigger. The reason is that browsers by default display images at their resolution in CSS
px
units. On high-dpi screens, one
px
unit covers more than one screen pixel, so if you use multiple screen pixels to display one image pixel, you get a blurry result.
Now that we interpret
size
of a
Figure
in device-independent pixels like CSS, we can display png images with the
text/html
MIME type, annotated with the original size via the
width
and
height
HTML attributes. This way, a
Figure
with
size = (600, 450)
will display at the same size, no matter how high or low
px_per_unit
is set. To give a better default experience, we increased
px_per_unit
to
2
so that outputs in Pluto and other notebooks will look sharp no matter if they're later viewed on a regular or high-dpi screen (at the cost of increased file size).
Here's a screenshot from a Macbook running Pluto with the previous Makie v0.19. The default png output is large (800x600 CSS pixels is larger than Pluto's output area size at 700px width, so we're even seeing a slightly scaled-down version). It's not sharp because the image only contains 800x600 pixels while the screen has double the pixels to fill.
And here is Makie v0.20. The image has the correct size of 600x450 CSS pixels, slightly smaller than Pluto's output area. And it's sharp, because it contains 1200x900 real pixels.
Makie has a new
experimental declarative API called
SpecApi
. While its interface is not yet stable, we are at a point where we want to ask the community to join forces with us in testing what works and what doesn't, so we're releasing a first version in 0.20. Note that experimental means that we may change this API at any time, so you should not build code intended to have a longer shelf life on top of it, yet.
Before going into the details, here is an example of the
SpecApi
in use:
using GLMakie
using DelimitedFiles
using Makie.FileIO
import Makie.SpecApi as S
volcano = readdlm(Makie.assetpath("volcano.csv"), ',', Float64)
brain = load(Makie.assetpath("brain.stl"))
r = LinRange(-1, 1, 100)
cube = [(x .^ 2 + y .^ 2 + z .^ 2) for x = r, y = r, z = r]
density_plots = map(x -> S.Density(x * randn(200) .+ 3x, color=:y), 1:5)
brain_mesh = S.Mesh(brain, colormap=:Spectral, color=[tri[1][2] for tri in brain for i in 1:3])
volcano_contour = S.Contourf(volcano; colormap=:inferno)
cube_contour = S.Contour(cube, alpha=0.5)
ax_densities = S.Axis(; plots=density_plots, yautolimitmargin = (0, 0.1))
ax_volcano = S.Axis(; plots=[volcano_contour])
ax_brain = S.Axis3(; plots=[brain_mesh], protrusions = (50, 20, 10, 0))
ax_cube = S.Axis3(; plots=[cube_contour], protrusions = (50, 20, 10, 0))
spec_column_vector = S.GridLayout([ax_densities, ax_volcano, ax_brain]);
spec_matrix = S.GridLayout([ax_densities ax_volcano; ax_brain ax_cube]);
spec_row = S.GridLayout([spec_column_vector spec_matrix], colsizes = [Auto(), Auto(4)])
f, ax, pl = plot(S.GridLayout(spec_row); figure = (; fontsize = 10))
We had been thinking for quite a while about a more declarative API which can describe complex plots as nested dicts without any
Observable
s. A major issue has been how to keep performant animations part of such an API. The
SpecApi
now offers this and allows
Observable{FigureSpec}
to create animations of whole layouts and complex plots via diffing.
There are at least three reasons why Makie can benefit from a declarative API:
AlgebraOfGraphics.jl has never supported animating Plots, because it has been too hard to create complex layouts from data observables in Makie. Since AlgebraOfGraphics is great for interactive data dashboards, this has always been a big limitation. With the new
SpecApi
, we will be able to rewrite AlgebraOfGraphics to be just a small generator of
SpecApi
objects, with much better compile times and the ability to create fully interactive and performant data dashboards.
Pluto and PlutoUI work best with a declarative Plotting API, and it has not been working well with Makie's observable approach. With the
SpecApi
this will change and should make the usage of Makie in Pluto much more enjoyable. Note, that the
deeper integration with Pluto will still need some help from Pluto's side.
Nested Dicts will work well for package independent serialization of plots. We could think of serializing complete complex figures to JSON and share them like that. Or we could update figures from within Javascript, making it easier to create animations in plots exported to HTML.
The above JSON export isn't implemented yet, but with this update we're getting a step closer to it.
To make it easier to work with the
SpecApi
, we decided not to use
Dict
s but very lightweight structs, which allow us to mirror the default Makie API:
import Makie.SpecApi as S # For convenience import it as a shorter name
S.Scatter(1:4) # create a single PlotSpec object
# Create a complete figure
spec = S.GridLayout(
S.Axis(; plots=[
S.Scatter(1:4)
])
)
spec_observable = Observable(spec)
f = plot(spec_observable) # Plot the layout into a figure
# Efficiently update the complete figure with a new FigureSpec
spec_observable[] = S.GridLayout(S.Axis(; title="lines", plots=[S.Lines(1:4)]))
f
If only an attribute like a color changes from red to green, Makie will only update that color efficiently. Otherwise, plots and blocks will be deleted and re-created, which is more costly. In general, the
SpecApi
will always be slower for animations than using pure
Observable
s, since we will always pay the cost of finding what has changed (the diffing), but it should be fast enough for lots of use cases.
convert_arguments
You can overload
convert_arguments
and return an array of
PlotSpec
s or a
GridLayoutSpec
. The main difference between those is, that returning an array of
PlotSpecs
can be plotted like any recipe into axes etc, while overloads returning a whole
GridLayoutSpec
can only be plotted to whole layout position (e.g.
figure[1, 1]
).
import Makie.SpecApi as S
struct PlotGrid
nplots::Tuple{Int,Int}
end
Makie.used_attributes(::PlotGrid) = (:color,)
function Makie.convert_arguments(::Type{Plot{plot}}, obj::PlotGrid; color=:black)
axes = [
S.Axis(plots=[S.Lines(cumsum(randn(1000)); color=color)])
for i in 1:obj.nplots[1],
j in 1:obj.nplots[2]
]
return S.GridLayout(axes)
end
f = Figure()
plot(f[1, 1], PlotGrid((1, 1)); color=Cycled(1))
plot(f[1, 2], PlotGrid((2, 2)); color=Cycled(2))
f
You can find more examples in the docs. Note that especially the
convert_argument
integration is
experimental and might be moved into another function and work quite differently in the future. Also, you will likely run into some bugs, especially for more complex layouts.
The PR to introduce the
SpecApi
had to fix quite a few internals to make this work:
Fixes multiple memory leaks for
delete!
Fixes large performance bugs in plot creation. See e.g. #3307, which was the worst offender.
Refactors plot creation API, to make things like
convert_arguments(...)::FigureSpec
easier to integrate and removes double
convert_arguments
from previous implementation. This will also make Unit support for Axes & Recipes, a.k.a axis converts
#3226 easier.
Moves cycler from Axis to Scene, so that Recipes and PlotSpec work with cycling, and cycling is applied more consistently.
Moves update tracking for the GLMakie
on_demand_renderloop
to conversion pipeline, so that less observables for tracking are created (more than halfs the size of objects created for each plot).
This release includes two fixes for surface plots. The first deals with NaN sections in the surface data which previously led to artifacts and lighting bugs across CairoMakie, GLMakie and WGLMakie. They should now consistently be excluded across all backends without lighting issues.
rs = range(-1, 1, length=100)
xs = cospi.(rs)
ys = rs
zs = [sinpi(x) for x in rs, y in rs]
zs[52:68, 60:90] .= NaN
fig = Figure()
ax = LScene(fig[1, 1], show_axis = false)
surface!(ax, xs, ys, zs, backlight = 1)
fig
Improving NaN-related behavior also surfaced a rendering bug which has also been fixed now. Specifically, the vertex positions were placed tightly against the edges like for
image
while colors were centered between those vertices like in
heatmap
. This resulted in colors at the edge of a surface being constant. The fix is most noticeable with very low-resolution surfaces, where the distances between vertices are large. The difference is almost imperceptible for higher-resolution surfaces, which is why the problem had gone unnoticed for a while.
data = [
1 2 3;
2 2 2;
3 2 1
]
fig = Figure(size = (800, 400))
# emulate old behaviour
ax = Axis(fig[1, 1], title = "old")
emulated = [
1.0 1.0 1.5 2.0 2.5 3.0 3.0;
1.0 1.0 1.5 2.0 2.5 3.0 3.0;
1.5 1.5 1.75 2.0 2.25 2.5 2.5;
2.0 2.0 2.0 2.0 2.0 2.0 2.0;
2.5 2.5 2.25 2.0 1.75 1.5 1.5;
3.0 3.0 2.5 2.0 1.5 1.0 1.0;
3.0 3.0 2.5 2.0 1.5 1.0 1.0;
]
surface!(ax, 1..3, 1..3, emulated, shading = NoShading, colormap = :buda)
scatter!(
ax, [Point3f(x/3, y/3, 4) for x in (4,6,8) for y in (4,6,8)], color = :transparent,
strokewidth = 1, strokecolor = :black, markersize = 20)
xlims!(ax, 1, 3)
ylims!(ax, 1, 3)
ax = Axis(fig[1, 2], title = "new")
surface!(ax, data, shading = NoShading, colormap = :buda)
scatter!(
ax, [Point3f(x, y, 4) for x in 1:3 for y in 1:3], color = :transparent,
strokewidth = 1, strokecolor = :black, markersize = 20)
xlims!(ax, 1, 3)
ylims!(ax, 1, 3)
fig
We have replaced the
SurfaceLike
trait and its children
ContinousSurface
and
DiscreteSurface
with a few new traits. These traits include:
CellGrid <: GridBased
for plots like
heatmap
, replacing
DiscreteSurface
. It interprets (N, M) input coordinates as centers of cells and outputs (N+1, M+1) coordinates marking the corners of those cells.
VertexGrid <: GridBased
for plots such as
surface
and
contour
. This replaces
ContinuousSurface
with the small change that generated x and y values now start at 1 rather than 0. This moves vertices to integer values and makes the respective plots compatible with
heatmap
ImageLike
for
image
plots. This conversion trait assumes that the plot is drawn on a regular rectangle, outputting two intervals
xmin..xmax
and
ymin..ymax
. With no x or y values passed this assumes the 0 as the starting value like
ContinuousSurface
did.
With this update we have merged a number of changes and additions to
Camera3D
, the default camera for 3D
Scene
s and
LScene
s.
Zooming has been reverted from an fov based zoom to a translation of
eyeposition
. This removes the change in "perspectiveness" when zooming.
We added a new interaction which allows you to focus on a geometry by clicking on it with the left alt key pressed.
To avoid previous issues with excessive data clipping after zooming, we now have different clipping modes. The default
clipping_mode = :bbox_relative
scales
near
and
far
to be just outside the scenes bounding box while also keeping near in front of the camera. For this to work you may need to call
center!(scene)
or
update_cam!(scene[, cam], bbox[, center = true])
explicitly in some cases. Other modes include
:view_relative
which scales near and far by
norm(eyeposition - lookat)
and
:static
which directly passes these values on.
Translation speed now scales with the zoom level and fov to feel more consistent.
We now take the fov into consideration when centering the camera.
We adjusted some of the default key bindings to be more accessible/consistent. These include eyeposition-based zooming changing from page up/down to b/n, and the camera reset changing from the home button to control + left mouse button.
We added
update_cam!(scene[, cam], azimuth, elevation[, radius = current, center = lookat])
which adjusts the camera position according to the given angles.
We have reworked lighting in GLMakie, adding more light types and allowing for multiple light sources at once, and we have adjusted the default setup across all backends. Furthermore we have made some general improvements to the consistency of lighting between plot types and backends.
Before this release, the default light setup included an
AmbientLight
which sets a base brightness for the scene and a
PointLight
which is kept at the camera position. We have changed this to be an
AmbientLight
with a
DirectionalLight
. A
DirectionalLight
works like the sun whose rays are effectively parallel when they arrive on earth. This makes adjusting lighting easier because illumination is the same for all similarly-oriented surfaces in the scene, no matter where they are placed. Before, the default point light was always placed exactly at camera position, which lights everything that the camera can see. Photographers will know that placing a light at camera position removes shadows and therefore depth cues, which is not desirable when we want to assess the three-dimensionality of objects. Our new default directional light now shines down at an angle relative to the camera, which generally makes it easier to see curvature of objects.
We have also adjusted the default values of
diffuse
and
specular
that a 3D plot carries. They changed from
0.4 -> 1.0
and
0.2 -> 0.4
respectively, with light intensities (given through the light color) going down to compensate. This overall makes plots a bit less shiny and cleans up the overexposure you may sometimes see:
using Makie.FileIO
fig = Figure(backgroundcolor = :black, size = (800, 400))
Label(fig[1, 1], "Old defaults", fontsize = 16, tellwidth = false, color = :white)
Label(fig[1, 2], "New defaults", fontsize = 16, tellwidth = false, color = :white)
# Emulate old lighting (does not include changes to backlight)
lights = [
AmbientLight(RGBf(0.55, 0.55, 0.55)),
PointLight(RGBf(1, 1, 1), Point3f(3, 3, 3))
]
ax1 = LScene(fig[2, 1], show_axis = false, scenekw = (lights = lights, ))
meshscatter!(
ax1, [Point3f(0, 0, 0)], color = :white,
diffuse = 0.4, specular = 0.2,
backlight = 1, fxaa = true
)
# new defaults
ax2 = LScene(fig[2, 2], show_axis = false)
meshscatter!(
ax2, [Point3f(0, 0, 0)], color = :white,
backlight = 1, fxaa = true
)
fig
rs = range(-1, 1, length=100)
xs = cospi.(rs)
ys = rs
zs = [(y + 0.5) * sinpi(x) for x in rs, y in rs]
fig = Figure(backgroundcolor = :black, size = (800, 400))
Label(fig[1, 1], "Old defaults", fontsize = 16, tellwidth = false, color = :white)
Label(fig[1, 2], "New defaults", fontsize = 16, tellwidth = false, color = :white)
# Emulate old lighting (does not include changes to backlight)
lights = [
AmbientLight(RGBf(0.55, 0.55, 0.55)),
PointLight(RGBf(1, 1, 1), Point3f(3, 3, 3))
]
ax1 = LScene(fig[2, 1], show_axis = false, scenekw = (lights = lights, ))
surface!(
ax1, xs, ys, zs, colormap = [:white],
diffuse = 0.4, specular = 0.2,
backlight = 1, fxaa = true
)
# new defaults
ax2 = LScene(fig[2, 2], show_axis = false)
surface!(
ax2, xs, ys, zs, colormap = [:white],
backlight = 1, fxaa = true
)
fig