Value Your Partner by Modelling using Julia Programming Language

Value Your Partner by Modelling using Julia Programming Language

By Muhammad Arief Mulyana

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=pnpt, 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.

Article content

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).

Article content

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.

Article content

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.

Article content

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.

Article content

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.

Article content

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.

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

Article content
Figure 1. Desktop/monitor display output.
Article content
Figure 2. Mobile phone display output.
Video 1. Demo of how webpage based output works.

3.2. Animation using GLMakie

Video 2. GLMakie + CUDA accelerated animation.

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.

Article content
Figure 3. My precious partner, Herlina Mawarni Appearance.

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

  • The Julia Programming Language — official site and features. Julia
  • Bezanson, J., Karpinski, S., Shah, V. B., & Edelman, A., Julia: A Fast Dynamic Language for Technical Computing (foundational paper). MIT Mathematics
  • Makie.jl — documentation / overview of the high-performance Julia visualization ecosystem. Makie Docs
  • MakieOrg / GLMakie (GitHub) — backends and interactive 3D capabilities. GitHub
  • CUDA.jl — Julia’s primary package for programming NVIDIA GPUs. CUDA on JuliaGPU

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        


To view or add a comment, sign in

More articles by Muhammad Arief Mulyana

Others also viewed

Explore content categories