Value Your Partner by Modelling using Julia Programming Language
1. Introduction
These days Julia is everywhere in scientific computing — and for good reason. It blends the approachable syntax of a high-level language with the performance of compiled code, so you can write mathematical models directly and run them at speeds that rival C or Fortran [1]. Researchers and engineers pick Julia when they want numerical reliability, easy parallelism, and a workflow that moves from prototyping to production without rewriting performance-critical parts, thanks to its just-in-time compilation and multiple dispatch paradigm [2].
What makes Julia particularly attractive for 3D modelling is its combination of language features and ecosystem. Just-in-time compilation (LLVM), multiple dispatch, and a rich numeric core give you fast, predictable math. On top of that, high-performance visualization libraries such as Makie [3] (with backends like GLMakie for interactive OpenGL rendering [4]), GPU support via CUDA.jl [5], and animation tools let you render complex, interactive 3D scenes with much less lag than many traditional Python 3D backends. The result: you can implement a parametric model, iterate on it, and produce smooth, publication-quality 3D visualizations — all within the same Julia session.
This article — titled “Value Your Partner by Modelling using Julia Programming Language” — walks through exactly that workflow. The main technical goal is simple and visual: implement the parametric equations of a rose and render a 3D model that you can rotate, animate, and export. As a personal touch (the heart of this piece), the tutorial is inspired by my most precious partner, Herlina Mawarni. Her surname, Mawar, means “rose,” which is why a 3D rose makes the perfect example to demonstrate Julia’s direct, powerful approach to mathematical modelling. Herlina is my partner from our time in the Physics Department at Unpad, and this walkthrough is a heartfelt tribute — through code — to that inspiration.
2. Modelling & Methods
2.1. Mathematical Model
The 3D rose is generated by evaluating a parametric surface defined on a circular domain. The model uses an angular coordinate, θ, and a radial coordinate, r, which is normalized such that r∈[0,1]. The angular domain extends to θ_max=pn⋅pt, where pt=2π/ppr, ensuring the petal pattern repeats with a frequency controlled by the parameter ppr. Numerically, this continuous domain is discretized into a grid for computation.
The shape of each petal is created by a periodic modulation function. First, a normalized, folded modulation is defined as shown in Equation (1). This function is then used to define a petal profile scalar, x(θ), which acts as a radial multiplier that varies periodically with θ, as given in Equation (2). The parameter psps within this equation tunes the cross-sectional shape of the petals.
The model incorporates a slowly varying elevation angle, φ(θ), which determines how the petals open or curve upwards. This angle is derived from a linear interpolation between two parameters, α_0 and α_1, and is calculated using Equations (3) and (4).
To add thickness and curvature along the length of the petals, a radial correction term y(r,θ) is introduced. This term, defined in Equation (5), is scaled by the parameter pf and is influenced by the elevation angle.
The core of the model is the effective radius, R_2(r,θ), which combines the petal profile, the elevation angle, and the radial correction. This composite function, specified in Equation (6), ultimately determines the surface's geometry in 3D space.
Finally, the 3D coordinates of the rose surface are given by the parametric map X(r,θ)=(X,Y,Z) in Equation (7). This map projects the 2D (r,θ) domain into a complex, self-intersecting 3D form that resembles a rose.
To enable visual rendering and shading, a scalar field C(r,θ) is defined. This field, given in Equation (8), is computed as the Euclidean norm of the position vector X(r,θ). It effectively measures the distance from the origin to each point on the surface.
This scalar value C(r,θ) is then mapped to a color scale (such as violet-to-blue or a reversed "Hot" palette) to produce the final, vibrant visualization. In summary, the parameter ppr controls the number of petals, ps adjusts their cross-section, pf scales their curvature, and the α parameters govern the overall opening and elevation of the flower. The scalar field C provides the necessary value for applying color, which is crucial for highlighting the complex three-dimensional structure of the rose.
Recommended by LinkedIn
2.2. Methods
The interactive visualization is delivered as a self-contained, responsive web page. This approach precomputes the 3D surface data and embeds it within an HTML file that includes the necessary visualization library. Users can immediately explore the model in their browser without any additional computational overhead. The interface presents two synchronized 3D scenes showing the same geometric model with different color schemes—arranged side-by-side on larger screens and stacked vertically on mobile devices. Viewers can rotate, zoom, and pan the scenes using mouse or touch controls, with axis labels and grid styling maintained consistently across both views. The underlying architecture uses responsive layout techniques that automatically adapt to different screen sizes, and is designed to easily accommodate future interactive controls that could allow real-time adjustment of model parameters directly in the browser. The complete code for generating this interactive web visualization is available in the Appendix section.
For animated presentations, the rendering pipeline leverages the combined power of GLMakie for high-performance OpenGL visualization and CUDA acceleration for computational throughput. This hybrid architecture automatically detects and utilizes compatible NVIDIA GPUs to dramatically accelerate the mathematical evaluation of the complex 3D geometry, while GLMakie handles the real-time, hardware-accelerated rendering. When a GPU is available, the model's parametric equations are computed in parallel, enabling smooth animation of the evolving form with sophisticated color mapping and camera movements. The system maintains seamless transitions through parameter interpolation and perfect synchronization between multiple viewports. This setup also includes an automatic fallback to CPU computation, ensuring broad accessibility. The same framework that produces high-quality video exports can power interactive applications for real-time parameter exploration. The complete implementation for this GLMakie and CUDA-accelerated animation pipeline is provided in the Appendix.
3. Results
3.1. Webpage Based
3.2. Animation using GLMakie
4. Acknowledgement
I first met Herlina Mawarni (whom everyone calls Erlin) in the Physics Department at Padjadjaran University, and we truly connected starting in our fourth semester. It’s funny — at the beginning she was drawn to material physics, fascinated by the physical behavior of materials from bulk to nano and their processing, while I was more focused on computation and instrumentation; now, in our seventh–eighth semester, the roles have swapped somewhat: Erlin is concentrating on particle physics instrumentation and I’ve moved deeper into a specific corner of material physics (AMO — Atomic, Molecular, and Optical). I’m profoundly grateful to her for being my most precious partner, for sharing this journey, and for allowing me to celebrate our bond in this article — to translate that deep appreciation into a small technical tribute by modelling the rose that carries her name using the Julia programming language.
5. Conlusion
Beautiful isn't it? This little project shows how a few clear equations and a handful of Julia lines map directly to a vivid 3-D visualization, demonstrating the language’s strength: simplicity in expressing math and a straightforward workflow from formula to array to surface. Thanks to easy access to hardware acceleration — whether that’s GLMakie’s GPU-accelerated rendering for smooth animations or CUDA.jl for heavy numeric work — you get an approachable, reproducible pipeline that makes creating, exploring, and sharing high-quality scientific visualizations both fast and fun, all without rewriting your model in another language.
References
Appendix
Appendix A. Webpage code
using JSON
using Printf
# -------------------------
# (Your original math/data)
# -------------------------
ppr = 3.6
nr = 30
pr = 30
pn = 40
pf = 2
ps = 5/4
ol = [0.2, 1.02]
pt = (1/ppr)*pi*2
θ = range(0, stop=pn*pt, length=pn*pr+1)
r_vals = range(0, stop=1, length=nr)
R = [r for r in r_vals, _ in θ]
THETA = [t for _ in r_vals, t in θ]
x = 1 .- (((ps .* ((1 .- mod.(ppr .* THETA, 2pi) ./ pi).^2) .- 1/4).^2) ./ 2)
phi_vec = (pi/2) .* (range(ol[1], stop=ol[2], length=pn*pr+1)).^2
sin_phi = sin.(phi_vec')
cos_phi = cos.(phi_vec')
y = pf .* (R.^2) .* ((1.28 .* R .- 1).^2) .* sin_phi
R2 = (x .* (R .* sin_phi)) .+ (y .* cos_phi)
X = R2 .* sin.(THETA)
Y = R2 .* cos.(THETA)
Z = x .* ((R .* cos_phi) .- (y .* sin_phi))
C = sqrt.(X.^2 .+ Y.^2 .+ Z.^2)
# -------------------------
# trace dicts (Plotly.js)
# -------------------------
# IMPORTANT: keep the flower color exactly as before (violet -> blue)
trace1 = Dict(
"type" => "surface",
"x" => X,
"y" => Y,
"z" => Z,
"surfacecolor" => C,
"colorscale" => [[0.0, "blue"], [1.0, "violet"]], # unchanged
"showscale" => false,
"name" => "violet-blue",
"scene" => "scene" # explicitly assign to first 3D scene
)
# Keep the second plot and its hot reversed color exactly as before
trace2 = Dict(
"type" => "surface",
"x" => X,
"y" => Y,
"z" => Z,
"surfacecolor" => C,
"colorscale" => "Hot", # unchanged
"reversescale" => true, # unchanged
"showscale" => false,
"name" => "hot",
"scene" => "scene2" # explicitly assign to second 3D scene
)
data = [trace1, trace2]
# ---------- shared visual properties ----------
# Note: we intentionally REMOVE the Plotly layout title (we'll use the HTML header instead)
paper_bg = "black"
plot_bg = "black"
font_common = Dict("size" => 12, "color" => "white")
axis_title_size = 16
tick_size = 12
# title_size variable left available if you want to use it elsewhere
title_size = 40
function make_scene(domain_x, domain_y)
Dict(
"domain" => Dict("x" => domain_x, "y" => domain_y),
"bgcolor" => "black",
"xaxis" => Dict(
"title" => Dict("text" => "X", "font" => Dict("size" => axis_title_size, "color" => "white")),
"tickfont" => Dict("size" => tick_size, "color" => "white"),
"showbackground" => true,
"backgroundcolor" => "black",
"gridcolor" => "gray"
),
"yaxis" => Dict(
"title" => Dict("text" => "Y", "font" => Dict("size" => axis_title_size, "color" => "white")),
"tickfont" => Dict("size" => tick_size, "color" => "white"),
"showbackground" => true,
"backgroundcolor" => "black",
"gridcolor" => "gray"
),
"zaxis" => Dict(
"title" => Dict("text" => "Z", "font" => Dict("size" => axis_title_size, "color" => "white")),
"tickfont" => Dict("size" => tick_size, "color" => "white"),
"showbackground" => true,
"backgroundcolor" => "black",
"gridcolor" => "gray"
)
)
end
layout_common = Dict(
"paper_bgcolor" => paper_bg,
"plot_bgcolor" => plot_bg,
"font" => font_common
# intentionally NO "title" key here — we use the HTML header above the plot
)
# Horizontal layout (side-by-side)
scene1_H = make_scene([0.0, 0.5], [0.0, 1.0])
scene2_H = make_scene([0.5, 1.0], [0.0, 1.0])
layoutH = deepcopy(layout_common)
layoutH["scene"] = scene1_H
layoutH["scene2"] = scene2_H
# Add a small top margin to avoid the plot touching the header
layoutH["margin"] = Dict("t" => 12, "l" => 60, "r" => 60, "b" => 60)
# Vertical layout (stacked)
scene1_V = make_scene([0.0, 1.0], [0.5, 1.0])
scene2_V = make_scene([0.0, 1.0], [0.0, 0.5])
layoutV = deepcopy(layout_common)
layoutV["scene"] = scene1_V
layoutV["scene2"] = scene2_V
layoutV["margin"] = Dict("t" => 12, "l" => 60, "r" => 60, "b" => 60)
# -------------------------
# Write responsive HTML using Plotly.js CDN
# -------------------------
# We move the title into an HTML header above the plot, and use flexbox for layout
html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dear Herlina Mawarni My Precious Friend</title>
<style>
html, body { height:100%; margin:0; background: black; }
/* Container holds header + plot; header doesn't overlap the plot */
#container { display: flex; flex-direction: column; height: 100vh; }
#plot-title {
text-align: center;
color: white;
padding: 12px 10px;
box-sizing: border-box;
}
#plot-title h1 { margin: 0; font-weight: 600; font-size: 40px; line-height: 1.05; }
#plot { flex: 1 1 auto; width: 100%; min-height: 0; } /* flex so it fills remaining space */
/* Mobile adjustments */
@media (max-width: 700px) {
#plot-title h1 { font-size: 26px; }
#plot-title { padding: 10px 8px; }
}
/* Optional: keep plot background black and remove page scrollbars on mobile */
.js-plotly-plot { background: black !important; }
body, html { -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }
</style>
</head>
<body>
<div id="container">
<div id="plot-title"><h1>Dear Erlin, My beautiful and precious partner</h1></div>
<div id="plot"></div>
</div>
<!-- Plotly.js from official CDN -->
<script src="https://cdn.plot.ly/plotly-2.24.1.min.js"></script>
<script>
const data = $(JSON.json(data));
const layoutH = $(JSON.json(layoutH));
const layoutV = $(JSON.json(layoutV));
const WIDTH_THRESHOLD = 700;
// base config for Plotly
const baseConfig = {
responsive: true,
displaylogo: false
// keep modebar visible (you can still remove buttons if you like)
};
function renderAdaptive() {
const w = window.innerWidth;
const baseLayout = (w < WIDTH_THRESHOLD) ? layoutV : layoutH;
const chosenLayout = JSON.parse(JSON.stringify(baseLayout));
// adjust a small top margin depending on screen size (header already takes space)
if (w < WIDTH_THRESHOLD) {
chosenLayout.margin = chosenLayout.margin || {};
chosenLayout.margin.t = Math.max(8, chosenLayout.margin.t || 8);
} else {
chosenLayout.margin = chosenLayout.margin || {};
chosenLayout.margin.t = Math.max(12, chosenLayout.margin.t || 12);
}
Plotly.react('plot', data, chosenLayout, baseConfig);
}
renderAdaptive();
let resizeTimer = null;
window.addEventListener('resize', () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
renderAdaptive();
resizeTimer = null;
}, 120);
});
</script>
</body>
</html>
"""
html = replace(html, "\$(JSON.json(data))" => JSON.json(data))
html = replace(html, "\$(JSON.json(layoutH))" => JSON.json(layoutH))
html = replace(html, "\$(JSON.json(layoutV))" => JSON.json(layoutV))
outfile = "Selamat Ulang Tahun Erlin.html"
open(outfile, "w") do io
write(io, html)
end
@printf "Saved responsive file: %s\n" outfile
Appendix B. GLMakie + CUDA Accelerated code
using GLMakie
using Makie
using ColorSchemes
using Colors
using Printf
using Dates
using LinearAlgebra
using Random
using Distributions
# ------------------ CUDA detection (robust) ------------------
CUDA_AVAILABLE = try
import CUDA
CUDA.has_cuda()
catch e
@warn "CUDA.jl not available or failed to load. Falling back to CPU. Error: $e"
false
end
if CUDA_AVAILABLE
@info "CUDA available — using GPU acceleration for compute."
import CUDA # make CUDA symbols visible (CuArray, CUDA.rand, etc.)
end
# ---------------- user settings ----------------
outfile_mp4 = "flower_animation_glmakie_with_stars.mp4"
fps = 24
duration_sec = 30
frames = fps * duration_sec
size_px = (1920, 1080) # 1080p
# grid resolution
nr = 60
pr = 60
pn = 80
# interpolation ranges (Float32)
ppr_range = (Float32(1.0), Float32(5.4))
pf_range = (Float32(0.5), Float32(3.4))
ps_range = (Float32(0.5), Float32(2.0))
ol_start_range = (Float32(0.05), Float32(0.25))
ol_end_range = (Float32(0.6), Float32(1.4))
# color schemes
c_left_cs = ColorScheme([
RGB{Float32}(0.02f0, 0.03f0, 0.15f0),
RGB{Float32}(0.0f0, 0.25f0, 0.9f0),
RGB{Float32}(0.35f0, 0.7f0, 1.0f0),
RGB{Float32}(0.9f0, 0.95f0, 1.0f0)
])
c_right_cs = reverse(ColorSchemes.hot)
# helpers
lerp(a,b,t) = a + (b-a)*t
ease(t::Float32) = Float32(0.5) * (Float32(1.0) - cos(Float32(pi) * t))
use_cuda = CUDA_AVAILABLE
# ---------- NEW: global flower scale & camera elevation (45°) ----------
# Make the flower smaller by this factor (0.4..0.8 is typical). Adjust to taste.
const flower_scale = 0.55f0
# Camera base: elevation = 45° (pi/4), base azimuth = 45° so we look from upper-right.
const cam_elevation = Float32(pi/4) # 45 degrees up
const cam_base_az = Float32(pi/4) # 45 degrees azimuth (upper-right)
# ---------------- GPU-friendly compute_geometry ----------------
function compute_geometry_gpu(ppr::Float32, pf::Float32, ps::Float32, ol_range::Tuple{Float32,Float32})
pt = (1f0 / ppr) * (2f0 * Float32(pi))
θ_cpu = range(0f0, stop = Float32(pn) * pt, length = pn * pr + 1)
r_vals_cpu = range(0f0, stop = 1f0, length = nr)
if use_cuda
θ = CUDA.CuArray(Float32.(θ_cpu))
r_vals = CUDA.CuArray(Float32.(r_vals_cpu))
R = CUDA.repeat(r_vals, 1, length(θ)) # nr x nθ
THETA = CUDA.repeat(reshape(θ, 1, :), nr, 1) # nr x nθ
else
θ = Float32.(θ_cpu)
r_vals = Float32.(r_vals_cpu)
R = [Float32(r) for r in r_vals, _ in θ]
THETA = [Float32(t) for _ in r_vals, t in θ]
end
x = 1f0 .- (((ps .* ((1f0 .- mod.(ppr .* THETA, 2f0*pi) ./ Float32(pi)).^2) .- 1f0/4f0).^2) ./ 2f0)
phi_vec_cpu = (Float32(pi)/2f0) .* (range(ol_range[1], stop = ol_range[2], length = pn * pr + 1) .^ 2)
if use_cuda
phi_vec = CUDA.CuArray(Float32.(phi_vec_cpu))
sin_phi = sin.(phi_vec')
cos_phi = cos.(phi_vec')
else
sin_phi = sin.(phi_vec_cpu')
cos_phi = cos.(phi_vec_cpu')
end
y = pf .* (R.^2) .* ((1.28f0 .* R .- 1f0).^2) .* sin_phi
R2 = (x .* (R .* sin_phi)) .+ (y .* cos_phi)
X = R2 .* sin.(THETA)
Y = R2 .* cos.(THETA)
Z = x .* ((R .* cos_phi) .- (y .* sin_phi))
C = sqrt.(X.^2 .+ Y.^2 .+ Z.^2)
return (X, Y, Z, C)
end
# style helper
function style_axis!(ax)
ax.scene.backgroundcolor[] = RGB{Float32}(0f0,0f0,0f0)
ax.xgridvisible = false
ax.ygridvisible = false
ax.zgridvisible = false
ax.xticksvisible = false
ax.yticksvisible = false
ax.zticksvisible = false
ax.xlabelcolor[] = RGBA{Float32}(1f0,1f0,1f0,1f0)
ax.ylabelcolor[] = RGBA{Float32}(1f0,1f0,1f0,1f0)
ax.zlabelcolor[] = RGBA{Float32}(1f0,1f0,1f0,1f0)
return nothing
end
# ---------- initialize geometry + stars ----------
t0 = 0f0
se0 = ease(t0)
ppr0 = lerp(ppr_range[1], ppr_range[2], se0)
pf0 = lerp(pf_range[1], pf_range[2], se0)
ps0 = lerp(ps_range[1], ps_range[2], se0)
ol_start0 = lerp(ol_start_range[1], ol_start_range[2], se0)
ol_end0 = lerp(ol_end_range[1], ol_end_range[2], se0)
ol0 = (ol_start0, ol_end0)
Xg0, Yg0, Zg0, Cg0 = compute_geometry_gpu(ppr0, pf0, ps0, ol0)
X0 = use_cuda ? Array(Xg0) : Xg0
Y0 = use_cuda ? Array(Yg0) : Yg0
Z0 = use_cuda ? Array(Zg0) : Zg0
C0 = use_cuda ? Array(Cg0) : Cg0
# ---------- NEW: scale the initial geometry so the flower is smaller ----------
X0 .*= flower_scale
Y0 .*= flower_scale
Z0 .*= flower_scale
C0 .*= flower_scale
rng = MersenneTwister(12345)
nstars = 320
star_x_cpu = Float32.(rand(rng, Uniform(-2.2, 2.2), nstars))
star_y_cpu = Float32.(rand(rng, Uniform(-2.2, 2.2), nstars))
star_z_cpu = Float32.(rand(rng, Uniform(0.8, 3.0), nstars))
star_speed_cpu = Float32.(rand(rng, Uniform(0.008, 0.032), nstars))
star_phase_cpu = Float32.(rand(rng, Uniform(0.0, 2pi), nstars))
star_size_cpu = Float32.(rand(rng, Uniform(2.0, 6.0), nstars))
if use_cuda
star_x_gpu = CUDA.CuArray(star_x_cpu)
star_y_gpu = CUDA.CuArray(star_y_cpu)
star_z_gpu = CUDA.CuArray(star_z_cpu)
star_speed_gpu = CUDA.CuArray(star_speed_cpu)
star_phase_gpu = CUDA.CuArray(star_phase_cpu)
star_size_gpu = CUDA.CuArray(star_size_cpu)
end
# Observables (CPU arrays used by Makie)
X_obs = Observable(X0)
Y_obs = Observable(Y0)
Z_obs = Observable(Z0)
C_obs = Observable(C0)
star_x_obs = Observable(star_x_cpu)
star_y_obs = Observable(star_y_cpu)
star_z_obs = Observable(star_z_cpu)
star_size_obs = Observable(star_size_cpu)
star_halo_size_obs = Observable(star_size_cpu .* 2.6f0)
alpha_init = clamp.(0.30f0 .* (0.6f0 .+ 0.4f0 .* sin.(0f0 .+ star_phase_cpu)) .+ 0.15f0, 0.05f0, 1f0)
star_core_colors_obs = Observable([RGBA{Float32}(1f0,1f0,1f0,a) for a in alpha_init])
star_halo_colors_obs = Observable([RGBA{Float32}(1f0,1f0,1f0,a*0.28f0) for a in alpha_init])
# Build a single Figure and axes (figure resolution is the place to set output size)
fig = Figure(resolution = size_px, backgroundcolor = RGB{Float32}(0f0,0f0,0f0))
label = Label(fig[1, 1:2], "frame 0/0"; fontsize = 18, halign = :left, tellwidth = false,
color = RGBA{Float32}(1f0,1f0,1f0,1f0), padding = (6,6,6,6))
ax1 = Axis3(fig[2,1]; xlabel="X", ylabel="Y", zlabel="Z")
ax2 = Axis3(fig[2,2]; xlabel="X", ylabel="Y", zlabel="Z")
style_axis!(ax1); style_axis!(ax2)
# ---------- ADJUSTED axis limits (smaller box to match scaled flower) ----------
xlims!(ax1, -2f0, 2f0); ylims!(ax1, -2f0, 2f0); zlims!(ax1, -0.8f0, 2f0)
xlims!(ax2, -2f0, 2f0); ylims!(ax2, -2f0, 2f0); zlims!(ax2, -0.8f0, 2f0)
# create surfaces & scatter once using Observables
surface!(ax1, X_obs, Y_obs, Z_obs; color = C_obs, colormap = c_left_cs, shading = true)
surface!(ax2, X_obs, Y_obs, Z_obs; color = C_obs, colormap = c_right_cs, shading = true)
scatter!(ax1, star_x_obs, star_y_obs, star_z_obs; markersize = star_halo_size_obs, color = star_halo_colors_obs, transparency = true)
scatter!(ax1, star_x_obs, star_y_obs, star_z_obs; markersize = star_size_obs, color = star_core_colors_obs, transparency = true)
scatter!(ax2, star_x_obs, star_y_obs, star_z_obs; markersize = star_halo_size_obs, color = star_halo_colors_obs, transparency = true)
scatter!(ax2, star_x_obs, star_y_obs, star_z_obs; markersize = star_size_obs, color = star_core_colors_obs, transparency = true)
# ---------- camera params used each frame ----------
# base eye_dir set for 45° elevation and 45° azimuth
eye_dir = Float32[cos(cam_base_az)*cos(cam_elevation),
sin(cam_base_az)*cos(cam_elevation),
sin(cam_elevation)]
eye_dist_start = 2.2f0
eye_dist_end = 1.8f0
lookat_pos = (0f0, 0f0, 0f0)
attraction = 0.990f0
@info "Recording $frames frames directly into MP4 (no PNGs)..."
start_time = now()
# === FIXED RECORD CALL: do NOT pass `resolution=` here — figure already has resolution ===
try
GLMakie.record(fig, outfile_mp4, 1:frames; framerate = fps) do i
t = Float32((i-1) / (frames-1))
se = ease(t)
# morph params
ppr = lerp(ppr_range[1], ppr_range[2], se)
pf = lerp(pf_range[1], pf_range[2], se)
ps = lerp(ps_range[1], ps_range[2], se)
ol_start = lerp(ol_start_range[1], ol_start_range[2], se)
ol_end = lerp(ol_end_range[1], ol_end_range[2], se)
ol = (ol_start, ol_end)
# compute geometry (GPU if available), collect once to CPU
Xg, Yg, Zg, Cg = compute_geometry_gpu(ppr, pf, ps, ol)
Xcpu = use_cuda ? Array(Xg) : Xg
Ycpu = use_cuda ? Array(Yg) : Yg
Zcpu = use_cuda ? Array(Zg) : Zg
Ccpu = use_cuda ? Array(Cg) : Cg
# ---------- NEW: scale the geometry each frame ----------
Xcpu .*= flower_scale
Ycpu .*= flower_scale
Zcpu .*= flower_scale
Ccpu .*= flower_scale
# update surface Observables
X_obs[] = Xcpu
Y_obs[] = Ycpu
Z_obs[] = Zcpu
C_obs[] = Ccpu
# camera & slow spin
eye_dist = lerp(eye_dist_start, eye_dist_end, ease(se))
max_total_rotation = 0.12f0
az = max_total_rotation * se
c = cos(az); s = sin(az)
rotz = Float32[ c -s 0.0;
s c 0.0;
0.0 0.0 1.0 ]
# rotate the base eye_dir to keep subtle rotation while preserving 45° elevation
eye_vec = rotz * eye_dir * eye_dist
eye_pos = (eye_vec[1], eye_vec[2], eye_vec[3])
cam3d!(ax1.scene); cam3d!(ax2.scene)
cam1 = cameracontrols(ax1.scene)
cam1.lookat[] = lookat_pos
cam1.eyeposition[] = eye_pos
update_cam!(ax1.scene, cam1)
cam2 = cameracontrols(ax2.scene)
cam2.lookat[] = lookat_pos
cam2.eyeposition[] = eye_pos
update_cam!(ax2.scene, cam2)
# update stars (GPU or CPU)
if use_cuda
# vectorized GPU updates (no scalar indexing)
star_z_gpu .-= star_speed_gpu
star_x_gpu .*= attraction
star_y_gpu .*= attraction
# mask of stars to respawn
mask = star_z_gpu .< -0.6f0
if any(mask)
# generate replacement values on GPU in one shot
new_z = 0.8f0 .+ 2.2f0 .* CUDA.rand(Float32, nstars)
new_x = -2.2f0 .+ 4.4f0 .* CUDA.rand(Float32, nstars)
new_y = -2.2f0 .+ 4.4f0 .* CUDA.rand(Float32, nstars)
new_speed = 0.008f0 .+ (0.032f0 - 0.008f0) .* CUDA.rand(Float32, nstars)
new_phase = 2f0 * Float32(pi) .* CUDA.rand(Float32, nstars)
new_size = 2f0 .+ 4f0 .* CUDA.rand(Float32, nstars)
star_z_gpu .= ifelse.(mask, new_z, star_z_gpu)
star_x_gpu .= ifelse.(mask, new_x, star_x_gpu)
star_y_gpu .= ifelse.(mask, new_y, star_y_gpu)
star_speed_gpu .= ifelse.(mask, new_speed, star_speed_gpu)
star_phase_gpu .= ifelse.(mask, new_phase, star_phase_gpu)
star_size_gpu .= ifelse.(mask, new_size, star_size_gpu)
end
tw_gpu = @. 0.6f0 + 0.4f0 * sin(2.5f0 * Float32(pi) * (t * 3f0 + star_phase_gpu))
dist_factor_gpu = clamp.(1f0 .- (abs.(star_z_gpu) ./ 6f0), 0.2f0, 1f0)
alpha_vals_gpu = clamp.(0.30f0 .* tw_gpu .* dist_factor_gpu .+ 0.15f0, 0.05f0, 1f0)
# collect once to CPU for plotting
star_x_cpu = Array(star_x_gpu)
star_y_cpu = Array(star_y_gpu)
star_z_cpu = Array(star_z_gpu)
star_size_cpu = Array(star_size_gpu)
alpha_vals = Array(alpha_vals_gpu)
else
star_z_cpu .-= star_speed_cpu
star_x_cpu .*= attraction
star_y_cpu .*= attraction
for j in 1:nstars
if star_z_cpu[j] < -0.6f0
star_z_cpu[j] = Float32(rand(rng, Uniform(0.8, 3.0)))
star_x_cpu[j] = Float32(rand(rng, Uniform(-2.2, 2.2)))
star_y_cpu[j] = Float32(rand(rng, Uniform(-2.2, 2.2)))
star_speed_cpu[j] = Float32(rand(rng, Uniform(0.008, 0.032)))
star_phase_cpu[j] = Float32(rand(rng, Uniform(0.0, 2pi)))
star_size_cpu[j] = Float32(rand(rng, Uniform(2.0, 6.0)))
end
end
tw = @. 0.6f0 + 0.4f0 * sin(2.5f0 * Float32(pi) * (t * 3f0 + star_phase_cpu))
dist_factor = clamp.(1f0 .- (abs.(star_z_cpu) ./ 6f0), 0.2f0, 1f0)
alpha_vals = clamp.(0.30f0 .* tw .* dist_factor .+ 0.15f0, 0.05f0, 1f0)
end
# update star Observables
star_x_obs[] = star_x_cpu
star_y_obs[] = star_y_cpu
star_z_obs[] = star_z_cpu
star_size_obs[] = star_size_cpu
star_halo_size_obs[] = star_size_cpu .* 2.6f0
star_core_colors_obs[] = [RGBA{Float32}(1f0,1f0,1f0,a) for a in alpha_vals]
star_halo_colors_obs[] = [RGBA{Float32}(1f0,1f0,1f0,a*0.28f0) for a in alpha_vals]
# update label text
label.text = @sprintf("frame %3d/%3d ppr=%.3f pf=%.3f ps=%.3f", i, frames, Float32(ppr), Float32(pf), Float32(ps))
end
@info "Recording finished."
catch e
@error "Recording to MP4 failed: $e"
rethrow(e)
end
elapsed = now() - start_time
@info "Finished in $(Dates.value(elapsed)/1e9) seconds."
# Build GIF from MP4 (optional)
if isfile(outfile_mp4)
gif_fps = min(fps, 15)
palette_file = "palette.png"
gif_outfile = replace(outfile_mp4, ".mp4" => ".gif")
run(`ffmpeg -y -i $outfile_mp4 -vf "fps=$gif_fps,scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos,palettegen" $palette_file`)
run(`ffmpeg -y -i $outfile_mp4 -i $palette_file -filter_complex "fps=$gif_fps,scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos[x];[x][1:v]paletteuse" -loop 0 $gif_outfile`)
@info "Saved GIF: $gif_outfile"
end
nice! will be ok if I add them to https://beautiful.makie.org/dev/ ?
How cute is that? Love it!