Skip to content
LIBRARY

3D rendering and animations

MultibodyComponents.jl has an automatic 3D-rendering feature that draws a mechanism in 3D. This can be used to create animations of the mechanism's motion from a solution trajectory, as well as to create interactive applications where the evolution of time can be controlled by the user.

The functionality requires the user to install and load one of the Makie backend packages, e.g.,

julia
using GLMakie # Preferred

or

julia
using WGLMakie

Backend choice

GLMakie and WGLMakie produce much nicer-looking animations and are also significantly faster than CairoMakie. CairoMakie may be used to produce the graphics in some web environments if constraints imposed by the web environment do not allow any of the GL alternatives. CairoMakie struggles with the Z-order of drawn objects, sometimes making bodies that should have been visible hidden behind bodies that are further back in the scene.

After that, the render function is the main entry point to create 3D renderings. This function has the following methods:

  • render(model, prob::ODEProblem): this method creates an interactive figure corresponding to the mechanisms configuration at the specified initial condition.

  • render(model, solution): this method creates an animation corresponding to the mechanisms evolution in a simulation trajectory.

  • scene, time = render(model, solution, t::Real): this method opens an interactive window with the mechanism in the configuration corresponding to the time t. Display scene to display the interactive window, and change the time by either dragging the slider in the window, or write to the observable time[] = new_time.

To interactively explore initial conditions, ic_tuner opens a window with one slider per state variable next to the 3D rendering; dragging a slider redraws the mechanism live, and a button prints the defs = [var => val, …] array of the variables that were changed, ready to paste back into a problem definition.

Colors

Many components allows the user to select with which color it is rendered. This choice is made by providing a 4-element array with color values in the order (RGBA), where each value is between 0 and 1. The last value is the alpha channel which determines the opacity, i.e., 1 is opaque and 0 is invisible.

Visualization defaults

The World component exposes global default parameters that control the size of visualization shapes across all components. All dimensional defaults scale with nominal_length (default = 1), following the same convention as Modelica's MultiBody.World.

ParameterDefaultDescription
nominal_length1Base scale for all dimensional defaults
default_joint_lengthnominal_length / 10Length of joint cylinders (Revolute, etc.)
default_joint_widthnominal_length / 20Width/diameter of joint shapes
default_body_diameternominal_length / 9Diameter of body spheres
default_width_fraction20Rod/cylinder width = length / this fraction
default_body_color[0.50, 0.0, 0.50, 1.0]Default body color (RGBA)
default_rod_color[0.50, 0.0, 0.50, 1.0]Default rod/translation color (RGBA)
default_joint_color[1, 0, 0, 1]Default revolute/universal joint color (RGBA)
default_prismatic_color[0, 0.8, 1, 1]Default prismatic joint color (RGBA)
default_spring_color[0, 0, 1, 1]Default spring color (RGBA)
default_force_color[0, 1, 0, 0.5]Default force/torque color (RGBA)
default_sensor_color[0, 1, 0, 0.5]Default sensor color (RGBA)
default_arrow_diameternominal_length / 40Arrow shaft diameter for force/torque visualization
default_force_lengthnominal_length / 10Force/damper visualization length
default_force_widthnominal_length / 20Spring coil width / force shape width
default_N_to_m1000Force arrow scaling (N/m)
default_Nm_to_m1000Torque arrow scaling (Nm/m)

To change the overall scale of all visualizations, set nominal_length on the World component:

julia
@named world = World(nominal_length = 0.5)  # Half-size visualization

Individual defaults can also be overridden:

julia
@named world = World(default_joint_length = 0.2, default_body_diameter = 0.15)

Individual components can still override their own visualization parameters per-instance, e.g., Revolute(radius = 0.08).

Rendering the world frame

The display of the world frame can be turned off by setting world.render => false in the variable map.

Tracing the path of a frame in 3D visualizations

The path that a frame traces out during simulation can be visualized by passing a vector of frames to the render function using the traces keyword, e.g., render(..., traces=[frame1, frame2]).

Camera controls

The camera controls are inherited from Makie, see their documentation for more information. Of particular interest may be the keyboard shortcuts x, y, z, by holding one of these keys and dragging the mouse, the camera will rotate around the corresponding axis. Use keyword argument show_axis = true to function render or pass parameter world.render => true to ODEProblem to display plot axes and/or world axes in the plot.

Shape primitives – declarative rendering for components

MultibodyComponents uses an object-oriented rendering system. A component that wants to declare how it should be visualized includes one or more shape sub-components and sets their variables through equations. The rendering system then walks the component tree and draws every shape it finds.

All shape types are defined in dyad/shape.dyad and extend a common Shape base. The available primitives are:

ShapeTypical use
CylinderShapeRods, axles, wheels
SphereShapePoint masses, bodies
BoxShapePrismatic joints
ConeShapeTapered parts
ArrowShapeForce / torque vectors
SpringShapeSpring coils

Shape variables

Every shape carries the following variables, which the parent component sets in its relations section:

VariableDescription
r3D position of the shape origin in world frame
R3×3 rotation matrix (orientation) in world frame
r_shapeOffset from r to the actual shape origin, resolved in the shape frame
length_directionPrimary axis direction (unit vector in shape frame)
width_directionSecondary axis direction (unit vector in shape frame)
length, width, heightDimensions along the respective directions
colorRGBA color (parameter, 4-element array, values 0–1)
renderBoolean parameter that toggles visibility

Example: adding a sphere to a Body

The PlanarMechanics.Body component instantiates a SphereShape and passes its variables at the point of instantiation:

component Body
  extends MultibodyComponents.Renderable(
    color = MultibodyComponents.world_default_body_color())
  parameter radius::Real = 0.1
  shape = MultibodyComponents.SphereShape(
    render = render, color = color,
    r = [frame_a.x, frame_a.y, z_position],
    R = MultibodyComponents.RR(
      MultibodyComponents.axis_rotation(3, phi)),
    length_direction = [0, 0, 1],
    width_direction  = [1, 0, 0],
    length = 2 * radius,
    width  = 2 * radius,
    height = 2 * radius
  )
end

For a sphere, length, width and height are all set to the diameter. The position r lifts the 2D frame coordinates into 3D (with a configurable z_position), and R is the 3D rotation matrix corresponding to the planar angle phi.

Using multiple shapes

A single component can include several shapes. For example, SlipBasedWheelJoint uses three CylinderShape instances – one for the tire and two for cross-spokes that rotate with the wheel:

tire_shape = MultibodyComponents.CylinderShape(render = render, color = [0.1, 0.1, 0.1, 1.0])
rim1_shape = MultibodyComponents.CylinderShape(render = render, color = [0.8, 0.8, 0.8, 1.0])
rim2_shape = MultibodyComponents.CylinderShape(render = render, color = [0.8, 0.8, 0.8, 1.0])

Each shape gets its own position, rotation and dimensions passed as constructor arguments. The rim shapes use a different rotation matrix that includes the wheel's rolling angle, making them spin in the animation.

Custom render! methods

If the built-in shape primitives are not enough, you can implement a custom Julia method of render! that dispatches on your component. The signature is:

julia
function MultibodyComponents.render!(scene, ::typeof(MyComponent), sys, sol, t)
    # Draw into `scene` using Makie primitives.
    # `sys`  – the simplified subsystem for this component
    # `sol`  – the ODE solution
    # `t`    – an Observable{Float64} (current time)
    # Return `true` to signal that rendering was handled.
    return true
end

The rendering system walks the model tree recursively. When render! returns true for a component, its children are not visited further, giving you full control over the subtree's appearance. Return false (or omit the method) to let the default tree-walking continue into sub-components.

Rendering API

MultibodyComponents.render Function
julia
scene       = render(model, prob)
scene, time = render(model, sol, t::Real; framerate = 30, traces = [])
path        = render(model, sol, timevec = range(sol.t[1], sol.t[end], step = 1 / framerate); framerate = 30, timescale=1, display=false, loop=1)

Create a 3D animation of a multibody system

Arguments:

  • model: The unsimplified multibody model, i.e., this is the model before any call to structural_simplify.

  • prob: If an ODEProblem is passed, a static rendering of the system at the initial condition is generated.

  • sol: If an ODESolution produced by simulating the system using solve is passed, an animation or dynamic rendering of the system is generated.

  • t: If a single number t is provided, the mechanism at this time is rendered and a scene is returned together with the time as an Observable. Modify time[] = new_time to change the rendering.

  • timevec: If a vector of times is provided, an animation is created and the path to the file on disk is returned.

  • framerate: Number of frames per second.

  • timescale: Scaling of the time vector. This argument can be made to speed up animations (timescale < 1) or slow them down (timescale > 1). A value of timescale = 2 will be 2x slower than real time.

  • loop: The animation will be looped this many times. Please note: looping the animation using this argument is only recommended when display = true for camera manipulation purposes. When the camera is not manipulated, looping the animation by other means is recommended to avoid an increase in the file size.

  • filename controls the name and the file type of the resulting animation

  • traces: An optional array of frames to show the trace of.

  • show_axis = false: Whether or not to show the plot axes, including background grid.

Camera control

The following keyword arguments are available to control the camera pose:

  • x = 2

  • y = 0.5

  • z = 2

  • lookat = [0,0,0]: a three-vector of coordinates indicating the point at which the camera looks.

  • up = [0,1,0]: A vector indicating the direction that is up.

  • display: if true, the figure will be displayed during the recording process and time will advance in real-time. This allows the user to manipulate the camera options using the mouse during the recording.

Lighting

  • lights: an optional Vector{<:Makie.AbstractLight} forwarded to the underlying LScene via scenekw = (; lights). When nothing (default), Makie's default flat lighting is used. Provide a custom rig for a more dramatic look. Example — a key spotlight from above-front-right with a soft cool rim from behind and low ambient fill:
julia
using GLMakie: AmbientLight, SpotLight, PointLight, RGBf, Vec3f, Vec2f
lights = [
    AmbientLight(RGBf(0.12, 0.12, 0.12)),
    SpotLight(RGBf(2.2, 2.2, 2.2),
              Vec3f(0.4, 0.6, 0.4),                 # position
              Vec3f(0, 0, 0) - Vec3f(0.4, 0.6, 0.4), # direction (toward origin)
              Vec2f(deg2rad(15), deg2rad(35))),     # (inner, outer) cone half-angles
    PointLight(RGBf(0.45, 0.45, 0.6), Vec3f(-0.3, 0.4, -0.3)),
]
render(model, sol, 0.0; lights)

See also loop_render

MultibodyComponents.render! Function
julia
did_render::Bool = render!(scene, ::typeof(ComponentConstructor), sys, ctx)

Each component that can be rendered must have a render! method, called by render for every component in the system.

To keep rendering fast, render evaluates all components' time-varying quantities with a single batched observed function (one compile, not one per component). A render! method therefore works in two steps against the render context ctx: 2. Register the time-varying symbols it needs via the ctx-based helpers (get_fun(ctx, syms), get_frame_fun(ctx, frame), _shape_funs(ctx, sys), …). These return extractor closures v -> values over the per-frame value vector. Read constants eagerly from ctx.sol (parameter access; cheap, no observed build).

  1. Defer the actual drawing with defer!(ctx) do … end. Inside, build the Makie plot and wire it to the shared per-frame Observable ctx.vals:
julia
function render!(scene, ::typeof(MyShape), sys, ctx)
    f = get_fun(ctx, collect(sys.frame_a.r_0))   # register
    color = get_color(sys, ctx.sol, :gray)        # eager constant
    vals = ctx.vals
    defer!(ctx) do
        thing = @lift begin
            r = f($vals)                          # extract this component's slice
            Sphere(Point3f(r), 0.1f0)
        end
        mesh!(scene, thing; color)
    end
    true
end

Returns

A boolean indicating whether the component performed any rendering. Custom methods that draw should return true; returning false (the fallback) lets render recurse into the component's sub-components.

MultibodyComponents.loop_render Function
julia
loop_render(model, sol; framerate = 30, timescale = 1, max_loop = 5, kwargs...)

Similar to the method of render that produces an animation, but instead opens an interactive window where the time is automatically advanced in real time. This allows the user to manually manipulate the camera using the mouse is a live animation.

MultibodyComponents.ic_tuner Function
julia
fig = ic_tuner(model, prob::ODEProblem; kwargs...)

Open an interactive window for tweaking the initial condition of prob. A 3D rendering of model is shown on the right (as in render) and, on the left, one slider per state variable in unknowns(prob.f.sys). Dragging a slider redraws the mechanism live. A button below the sliders prints

julia
defs = [var => val, ]

containing only the variables whose slider has been moved from its starting value, ready to paste back into a problem definition.

Each slider starts at the variable's value in prob; if a variable has no value in the problem, its guess is used, and if there is no guess either, 0. Only the initial condition is varied — parameters are held fixed.

Arguments

  • model: the unsimplified multibody model (before structural_simplify).

  • prob: the ODEProblem whose initial condition is tuned.

Keyword arguments

  • size: window size. Defaults to a width of 1200 and a height that grows with the number of sliders.

  • range_span: a function start -> half_width setting each slider's range to start ± range_span(start). Defaults to start -> max(5, 2*abs(start)).

  • nsteps: number of slider steps (odd, so the starting value is exactly representable). Defaults to 301.

  • Camera/lighting keywords x, y, z, lookat, up, lights behave as in render.

See also render.