# Reusable Modal Blender-Render Setup

Records the working setup for GPU-rendering a Blender `.blend` on Modal.
Reuse this next time instead of rebuilding from scratch.

## 1. The image (already built — reuse by id)

- **Image id:** `im-icOskkA4bWjoP4rYxoPHX7`  (Blender 5.1.2, headless Cycles, OPTIX/CUDA GPU)
- Built from `nvidia/cuda:12.4.1-runtime-ubuntu22.04` + Python 3.11 + apt
  (ffmpeg, xz-utils, and headless GL/X libs) + the official
  `blender-5.1.2-linux-x64.tar.xz` extracted to `/opt/blender`,
  symlinked to `/usr/local/bin/blender`.
- A second image `im-V1GxFS5U84mZWPNq24Whoe` additionally bakes in the
  Molecular Nodes addon (164 MB) — only needed if you want MN atomic
  models to render (they need the addon *enabled before the .blend loads*).

To rebuild from scratch (compute_provider / modal kernel):
```python
import modal
BLENDER_URL = "https://download.blender.org/release/Blender5.1/blender-5.1.2-linux-x64.tar.xz"
img = (modal.Image.from_registry("nvidia/cuda:12.4.1-runtime-ubuntu22.04", add_python="3.11")
       .apt_install("curl","xz-utils","ffmpeg","libx11-6","libxrender1","libxi6",
                    "libxxf86vm1","libxfixes3","libxkbcommon0","libsm6","libice6",
                    "libgl1","libglu1-mesa","libegl1","libgomp1","libxext6")
       .run_commands(f"curl -L {BLENDER_URL} -o /tmp/b.tar.xz",
                     "mkdir -p /opt/blender && tar -xJf /tmp/b.tar.xz -C /opt/blender --strip-components=1",
                     "ln -s /opt/blender/blender /usr/local/bin/blender",
                     "rm /tmp/b.tar.xz"))
img.build(modal.App.lookup("blender-render", create_if_missing=True))
```

## 2. Key gotcha — OPTIX kernel compile is a one-time ~440 s cost PER CONTAINER

Cycles compiles the OPTIX kernel on the first render in a fresh container,
then caches it. So:
- The FIRST frame in any container takes ~440 s (compile + render).
- Every frame after that is ~14 s at 720p / 256 samples for this scene.

=> Do NOT estimate full-render cost from single-frame jobs (each pays the
compile once). Render CONTIGUOUS ranges in one Blender process, and use
FEW wide containers rather than many narrow ones, so the compile is
amortized. 4 containers × ~105 frames each was the sweet spot here.

## 3. Parallel render pattern (host.compute / repl kernel)

- Create a shared Modal Volume once: `modal.Volume.from_name("tcell-render-frames", create_if_missing=True)`.
- Fan out N containers, each with `provider_params.modal.volumes={'/frames':'tcell-render-frames'}`,
  each rendering its own frame sub-range into `/frames` via `render_range.py`.
- After all N finish, run ONE container (same volume mounted) that ffmpeg-encodes
  `/frames/frame_*.png` -> MP4, copies the mp4 to `./out/`, and harvests.

## 4. Render settings used (final deliverable)

- Engine CYCLES, GPU (OPTIX on A100), 256 samples, denoising ON, adaptive sampling.
- 1080p (1920×1080, render_range.py res_pct=100), fps 30, frames 1–420, PNG RGB sequence.
- Final encode: `ffmpeg -y -framerate 30 -i frame_%04d.png -c:v libx264 -preset slow -crf 16 -pix_fmt yuv420p -movflags +faststart animation.mp4`.
- Result: 14 s, ~3.1 MB. Steady-state ~30 s/frame @1080p; 4 parallel containers ≈ 45 min wall-time, ~2 A100-hours.
- (720p at res_pct=67 forces 1280×720 for even H.264 dims — was tested but not used for the final; it keeps ~44% of 1080p pixels, ~2.25× cheaper.)

## 5. Files
- `render_range.py`  — renders a contiguous [a,b] frame range at a given res%/samples into a dir.
- `fix_labels.py`    — one-off: rewrote the 3 fate labels to two lines to fix overlap.
