Mix.install([
{:curves, "~> 1.0"},
{:kino, "~> 0.19"}
])Points that pull
A Bezier curve is defined by a short list of points:
- the curve starts at the first point and ends at the last point,
- the points in between are control points: the curve doesn't go through them, it is pulled towards them, like a rope pulled by magnets.
The number of points decides the kind of curve: 2 points make a straight line, 3 a quadratic curve and 4 a cubic curve.
Curves.define_bezier([{0, 0}, {1, 4}, {3, -1}, {4, 3}])
|> Curves.Svg.render(width: 480, height: 360)
|> Kino.Image.new(:svg)The dashed lines join each control point to the end it pulls on: they show the direction the curve leaves the start, and the direction it arrives at the end.
Move the control points
The start {0, 0} and end {4, 0} stay put. Move the control points with the sliders and switch
between the three kinds of curve. Try:
- both control points high up, for a hill,
- one up and one down, for an S,
- control points past the ends, for loops and overshoots.
form =
Kino.Control.form(
[
kind:
Kino.Input.select(
"Kind",
[cubic: "Cubic (4 points)", quadratic: "Quadratic (3 points)", linear: "Line (2 points)"]
),
c1x: Kino.Input.range("Control 1: x", min: -2, max: 6, step: 0.25, default: 1),
c1y: Kino.Input.range("Control 1: y", min: -4, max: 4, step: 0.25, default: 3),
c2x: Kino.Input.range("Control 2: x (cubic only)", min: -2, max: 6, step: 0.25, default: 3),
c2y: Kino.Input.range("Control 2: y (cubic only)", min: -4, max: 4, step: 0.25, default: 3)
],
report_changes: true
)
frame = Kino.Frame.new()
render = fn %{kind: kind, c1x: c1x, c1y: c1y, c2x: c2x, c2y: c2y} ->
points =
case kind do
:cubic -> [{0, 0}, {c1x, c1y}, {c2x, c2y}, {4, 0}]
:quadratic -> [{0, 0}, {c1x, c1y}, {4, 0}]
:linear -> [{0, 0}, {4, 0}]
end
Kino.Layout.grid(
[
Kino.Markdown.new("`Curves.define_bezier(#{inspect(points)})`"),
points
|> Curves.define_bezier()
|> Curves.Svg.render(width: 480, height: 360, title: "#{kind}")
|> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(frame, render.(%{kind: :cubic, c1x: 1, c1y: 3, c2x: 3, c2y: 3}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)You already know these: CSS easing
If you've written CSS, you've used cubic Bezier curves! cubic-bezier(x1, y1, x2, y2) is a curve from
{0, 0} to {1, 1} with the control points {x1, y1} and {x2, y2}. Design your own easing below,
then press Play to feel what it does to a moving box.
# Remembers the latest numbers from the form, so the Play button can use them.
{:ok, latest} = Agent.start_link(fn -> %{x1: 0.68, y1: -0.55, x2: 0.27, y2: 1.55} end)
form =
Kino.Control.form(
[
x1: Kino.Input.range("x1", min: 0, max: 1, step: 0.01, default: 0.68),
y1: Kino.Input.range("y1", min: -1, max: 2, step: 0.01, default: -0.55),
x2: Kino.Input.range("x2", min: 0, max: 1, step: 0.01, default: 0.27),
y2: Kino.Input.range("y2", min: -1, max: 2, step: 0.01, default: 1.55)
],
report_changes: true
)
easing = fn %{x1: x1, y1: y1, x2: x2, y2: y2} ->
Curves.define_bezier([{0, 0}, {x1, y1}, {x2, y2}, {1, 1}])
end
frame = Kino.Frame.new()
render = fn css ->
Agent.update(latest, fn _ -> css end)
Kino.Layout.grid(
[
Kino.Markdown.new(
"CSS: `cubic-bezier(#{css.x1}, #{css.y1}, #{css.x2}, #{css.y2})`"
),
css |> easing.() |> Curves.Svg.render(width: 420, height: 360) |> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(frame, render.(Agent.get(latest, & &1)))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)# A box sliding from left to right, using your easing for "how far along" at each moment.
box = fn progress ->
x = 20 + progress * 460
Kino.Image.new(
"""
<svg xmlns="http://www.w3.org/2000/svg" width="560" height="100">
<rect width="100%" height="100%" fill="#f8f9fa"/>
<line x1="45" y1="80" x2="505" y2="80" stroke="#dee2e6" stroke-width="2"/>
<rect x="#{x}" y="30" width="50" height="50" rx="8" fill="#7048e8"/>
</svg>
""",
:svg
)
end
button = Kino.Control.button("Play")
frame = Kino.Frame.new()
Kino.Frame.render(frame, box.(0.0))
Kino.listen(button, fn _event ->
curve = easing.(Agent.get(latest, & &1))
for step <- 0..50 do
{_x, progress} = Curves.solve!(curve, step / 50)
Kino.Frame.render(frame, box.(progress))
Process.sleep(25)
end
end)
Kino.Layout.grid([button, frame], columns: 1)Try a curve that goes below 0 or above 1: the box backs up before it starts, or overshoots and
comes back. That's how "bouncy" animations are made.
Too many points?
A single Bezier curve takes 2 to 4 points. With more, solving returns an error:
[{0, 0}, {1, 1}, {2, 0}, {3, 1}, {4, 0}]
|> Curves.define_bezier()
|> Curves.solve(0.5)For longer paths, chain curves together with a spline. That's the next chapter.
Next
Chapter 3: splines.