import bpy — the full Blender Python module, cross-compiled for iOS arm64
(CPython 3.14). Build scenes, run modifiers and physics, simulate smoke, track
footage, exchange USD, and render with Cycles on the Apple GPU (Metal) with
OpenImageDenoise — entirely on-device, no network, no JIT. First public build
of Blender's bpy on iOS — and at feature parity with the PyPI bpy wheel
for everything that doesn't need a GPU windowing backend (see
Gaps vs PyPI).
- Blender: 5.3.0 (alpha branch)
- Module:
bpy/__init__.so(~231 MB) +bpy/5.3/scripts+ datafiles (+bpy/lib/libusd_ms+bpy/usd_resourcesfor USD) - Render: Cycles SVM on CPU and Apple Metal GPU; OpenImageDenoise
- SwiftPM product:
Blender(see Packaging)
| Area | Status | Notes |
|---|---|---|
| Cycles | ✅ | Path tracer — SVM shaders (no-JIT), CPU + Metal GPU, path guiding (OpenPGL) |
| OpenImageDenoise | ✅ | Intel OIDN AI denoiser (CPU); check via _cycles.with_openimagedenoise |
| Embree | ✅ | CPU BVH acceleration |
| OpenSubdiv | ✅ | Subdivision surfaces / multires |
| OpenVDB / NanoVDB | ✅ | Volumes + voxel remesh + fluid caches |
| Alembic | ✅ | .abc import/export |
| USD + MaterialX | ✅ | .usd/.usda/.usdc import/export (OpenUSD 26.03 monolithic + usdImaging); MaterialX export path. Script-registered USDHooks no-op (USD built without its Python bindings) |
| Bullet | ✅ | Rigid-body / physics |
| Mantaflow | ✅ | Fluid / smoke — simulation verified on device (bake → OpenVDB cache, nonzero density) |
| Motion tracking (libmv) | ✅ | ceres-solver camera/object solve — bpy.ops.clip.solve_camera, full MovieTracking RNA |
| Audio (audaspace) | ✅ | The aud module — generate/filter/sequence sounds; loads WAV/AIFF/AU/CAF via libsndfile. No playback device (same as PyPI — see gaps) |
| Ocean (FFTW) | ✅ | Ocean modifier |
| Manifold + GMP | ✅ | Exact-solver boolean |
| Freestyle | ✅ | NPR line render |
| OpenColorIO | ✅ | Color management |
| Image I/O | ✅ | 10/10 on-device: PNG · JPEG · OpenEXR · TIFF · WebP · JPEG2000 · Cineon · DPX · Targa · BMP (+ DDS/HDR read) |
| Potrace / Haru | ✅ | Trace-to-curve / grease-pencil PDF export |
| HarfBuzz / FreeType / fribidi | ✅ | Text objects + full i18n — 50 locale catalogs bundled (bpy.app.translations; PyPI ships i18n off, so this exceeds it). Verified: pgettext_iface("Render") → レンダー (ja) / 算繪 (zh_HANT) |
| OBJ / PLY / STL / FBX / glTF | ✅ | Mesh I/O — FBX via the new C++ bpy.ops.wm.fbx_*; glTF exports/imports with Draco + MeshOptimizer compression (bridges cross-compiled for iOS; verified 5.15× encode + round-trip decode) |
| Geometry nodes | ✅ | Full node graph |
| FFmpeg video | ✅ | H.264/H.265 (Apple VideoToolbox) · MPEG-4 · FFV1 · QTRLE · … — see Video output |
| Cycles OSL | ❌ | Needs LLVM JIT — impossible under iOS W^X; script shader node only (SVM nodes work) |
Eevee + gpu module |
✅ (Metal) | Offscreen Metal GHOST backend — gpu.init(), GPUOffScreen, BLENDER_EEVEE render all work on device (see GPU backend) |
The definitive runtime check for a compiled-in feature is
bpy.app.build_options.<name> (e.g. .cycles, .openvdb, .libmv, .fluid,
.usd, .international). OIDN is the exception — Blender never exposed it
there; use import _cycles; _cycles.with_openimagedenoise.
Audited against Blender's own wheel build config
(build_files/cmake/config/blender_release.cmake + bpy_module.cmake) and
probed at runtime on device. Things people assume are gaps but aren't: the
PyPI wheel itself ships with CoreAudio/OpenAL/JACK playback off, NDOF
off, IME off — and i18n off (this build compiles i18n in and bundles 50
locale catalogs, so it actually exceeds the wheel there).
| PyPI wheel (macOS arm64) | this iOS build | |
|---|---|---|
| Cycles CPU + Metal GPU, Embree, OIDN, guiding | ✅ | ✅ (Metal renders verified on device) |
| USD + MaterialX, Alembic, all mesh I/O | ✅ | ✅ |
| glTF Draco / MeshOptimizer compression | ✅ | ✅ (bridge dylibs cross-compiled for iOS; 5.15× encode + decode verified) |
| Fluid, ocean, bullet, tracking (libmv) | ✅ | ✅ |
| Image formats / video (ffmpeg) | ✅ | ✅ (H.264/265 via VideoToolbox) |
aud present, no playback device |
✅ | ✅ (identical: null device) |
i18n (bpy.app.translations) |
❌ off | ✅ on, 50 locale catalogs bundled — translations work on device (exceeds the PyPI wheel) |
Eevee render + working gpu module |
✅ (Metal) | ✅ (Metal offscreen — verified on device) |
| Hydra render-delegate framework | ✅ flag on | ❌ off (Storm needs a full GPU windowing stack) |
| Cycles OSL script nodes | ✅ | ❌ (LLVM JIT emits runtime code — forbidden by iOS W^X; structurally impossible) |
| OpenMP (mantaflow/oceansim threading) | ✅ | ❌ (perf only; Cycles uses TBB regardless; iOS has no libomp) |
| Rubberband (VSE audio time-stretch quality) | ✅ | ❌ (falls back to plain resampling; moot on iOS — the null audio device can't return stretched buffers anyway) |
bpy.app.build_hash + build_date/build_commit_date/build_branch/… |
real hash | ✅ WITH_BUILDINFO=ON — real git hash c9dd766ce9c2, dates, branch, platform, type all populated (device-verified) |
| OpenXR / NDOF / IME / audio playback | ❌ off | ❌ off (parity) |
Runtime notes from the device probes:
- Cycles Metal really renders (kernels compile on first render, cache after). On small scenes the CPU is often faster (GPU dispatch overhead dominates); the GPU pays off at higher resolutions/sample counts — benchmark your workload.
gpu.init()works (as of the Metal-backend port). It creates an offscreen Metal context via a headlessGHOST_ContextMTLand reportsgpu.platform.backend_type_get() == 'METAL'.GPUOffScreenrender + read-back andBLENDER_EEVEEstill renders both verified on device. (Earlier headless builds had no GPU backend and did deadlock inWM_system_gpu_context— that is fixed; see GPU module / Eevee.)- Add-ons: the 13 bundled add-ons match PyPI 5.x
addons_coreexactly (io_scene_gltf2,io_scene_fbx,rigify,pose_library,node_wrangler, …). - Verified on a real iPad (Air 11" M3, 2026-07-03, module-coverage suite):
core data/ops,
mathutils(incl. kdtree/bvhtree/noise),bmesh+ops, the modifier stack (OpenSubdiv/Array/Solidify/Boolean), curves/text/metaballs, geometry nodes, animation + drivers + constraints (5.x layered actions), grease pencil, 10/10 image saves,aud+sndfile decode, movieclip + tracking API, USD & Alembic round-trips, OBJ/STL/PLY/glTF,gpuMetal offscreen readback, Eevee, Freestyle, the compositor, the sequencer, and Cycles-Metal renders — all pass in one run. blffonts: Blender 5.x ships its fonts as.woff2; iOS FreeType has no brotli, so the bundle instead ships them decompressed to TrueType/OpenType (same filenames) —blfand text rendering work. (An early build that shipped the raw.woff2couldn't load any font andblfaborted; fixed.)- Ocean modifier tip:
geometry_mode='DISPLACE'only moves the base mesh's existing vertices — on a default 4-vert plane the water stays flat. Use'GENERATE'(and setviewport_resolutionto matchresolution) to get the actual wave grid.
| 4.x | 5.x |
|---|---|
scene.node_tree (compositor) |
scene.compositing_node_group — a standalone CompositorNodeTree in bpy.data.node_groups, output via NodeGroupOutput |
SequenceEditor.sequences |
.strips |
strips.new_effect(..., frame_end=N) |
strips.new_effect(..., length=N) |
action.fcurves |
layered actions: action.layers[0].strips[0].channelbag(action.slots[0]).fcurves |
CompositorNodeBlur.size_x/.size_y |
socket inputs on the node |
Unlike a stock headless wheel, this build has a real GPU backend on iOS: an
offscreen Metal context (no window). It powers the gpu module, GPUOffScreen,
the realtime compositor, and the Eevee render engine.
import bpy, gpu
gpu.init() # creates the offscreen Metal context
gpu.platform.backend_type_get() # -> 'METAL'
# Eevee still render:
scn = bpy.data.scenes[0]
scn.render.engine = 'BLENDER_EEVEE'
scn.render.filepath = '/path/out.png'
bpy.ops.render.render(write_still=True) # ~9 s for a lit 400x300 scene on an M-series iPadHow it works / caveats:
GHOST_SystemHeadlessgains a Metal case that builds a windowlessGHOST_ContextMTL(MTLCreateSystemDefaultDevice+ aCAMetalLayer, noNSView). The Metal backend is compilediphoneos-clean (managed-storage paths map to shared memory; macOS-only intrinsics guarded).- GLSL shaders cross-compile to MSL at runtime (baseline raised to MSL 3.0 on
iOS so EEVEE's SIMD-group ops —
simd_or/simd_min/simd_max— are declared). bpy.ops.render.render()runs on the calling thread. Metal context creation asserts the main thread in debug builds; call it from your main / Python thread.- Eevee's first render compiles its shader library (a few seconds), then caches.
- Recipes:
bpy_ios_metal_gpu.patch(source) +-DWITH_METAL_BACKEND=ON.
import bpy, math
# clean slate (headless: use bpy.data, NOT bpy.context.scene which can be None)
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
scene = bpy.data.scenes[0]
# a subdivided monkey
bpy.ops.mesh.primitive_monkey_add()
mon = bpy.context.active_object # NOT bpy.data.objects[-1] — see gotchas
mon.modifiers.new("s", "SUBSURF").levels = 2
# camera + sun
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.location = (0, -4.5, 1.2); cam.rotation_euler = (math.radians(75), 0, 0)
scene.collection.objects.link(cam); scene.camera = cam
sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", "SUN"))
sun.data.energy = 4.0; sun.rotation_euler = (math.radians(45), 0, math.radians(30))
scene.collection.objects.link(sun)
# render on the Apple GPU with OpenImageDenoise
scene.render.engine = "CYCLES"
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "METAL"
(prefs.refresh_devices if hasattr(prefs, "refresh_devices") else prefs.get_devices)()
for d in prefs.devices: d.use = True
prefs.metalrt = "OFF" # iOS: skip the hardware-RT path
prefs.kernel_optimization_level = "OFF" # iOS: skip the slow specialized kernel compile
scene.cycles.device = "GPU"
scene.cycles.samples = 24
scene.cycles.use_denoising = True
scene.cycles.denoiser = "OPENIMAGEDENOISE"
scene.render.resolution_x, scene.render.resolution_y = 480, 360
scene.render.filepath = "render.png" # NB: /tmp is read-only on iOS — use a relative path or ~/Documents
bpy.ops.render.render(write_still=True)GPU on a real device: the Metal path works (the iOS-only
setShouldMaximizeConcurrentCompilation: unrecognized-selector crash is patched
in the module). Cycles compiles its Metal kernels on the first render of a
session (up to ~3 min on older iPads, seconds on M-class; no progress callback
during the compile); kernels then cache and later renders run in seconds. Keep
prefs.metalrt = "OFF" and prefs.kernel_optimization_level = "OFF" (above) to
keep that first compile manageable. CPU (scene.cycles.device = "CPU") has
no compile wait and is often faster for small stills — the GPU wins as
resolution/samples grow.
In CodeBench you usually don't write any of the render/preview plumbing: the bundled startup handler shows a tqdm progress bar during every render and opens the interactive viewer (orbit + a "Rendered" toggle) automatically — see Interactive preview.
Full smoke/liquid simulation works on device — domain + flow objects, bake to an OpenVDB cache, and render the volume:
import bpy, os
sc = bpy.data.scenes[0]
bpy.ops.mesh.primitive_cube_add(size=4)
dom = bpy.context.active_object
m = dom.modifiers.new("Fluid", "FLUID"); m.fluid_type = "DOMAIN"
m.domain_settings.domain_type = "GAS"
m.domain_settings.resolution_max = 48
m.domain_settings.cache_directory = os.path.expanduser("~/Documents/fluid_cache")
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, location=(0, 0, -1.2))
f = bpy.context.active_object.modifiers.new("Flow", "FLUID"); f.fluid_type = "FLOW"
f.flow_settings.flow_type = "SMOKE"; f.flow_settings.flow_behavior = "INFLOW"
sc.frame_start, sc.frame_end = 1, 24
with bpy.context.temp_override(object=dom, active_object=dom, selected_objects=[dom]):
bpy.ops.fluid.bake_data() # → .vdb frames in the cache dirTwo iOS-specific fixes are baked into the module (upstream-clean, in
bpy_ios_source.patch): fluid teardown no longer aborts the process, and
the manta Python bindings are finalized even when something imports manta
before the first solve (CodeBench's completion indexer does) — without that fix
every inherited grid method (LevelsetGrid.setConst, …) was missing and the
solver script failed.
bpy.app.build_options.libmv == True. Load footage as a MovieClip, add
markers, and run the camera solver — the full desktop pipeline:
clip = bpy.data.movieclips.load("~/Documents/shot.mp4")
# ... add/track markers via bpy.ops.clip.* ...
bpy.ops.clip.solve_camera() # ceres-based reconstruction
print(clip.tracking.reconstruction.average_error)(ceres-solver is cross-built against Blender's own Eigen, so solver results match desktop.)
The aud module is bundled: generate, filter, mix, and sequence audio, and
load files (WAV/AIFF/AU/CAF via libsndfile; compressed formats via ffmpeg).
import aud
s = aud.Sound.file("~/Documents/beep.wav").volume(0.5).fadein(0, 0.1)
buf = aud.Sound.buffer(s) # process fully in memoryaud.Device() returns a null device (no playback) — identical to the PyPI
wheel, which also ships without an audio backend. Route rendered audio to a
file, or play files with the app's own player. Do not call
aud.Sound.buffer() / .cache(): with the null device's 0 Hz rate the full
decode never terminates (spins forever). Filter graphs, file loading, and
explicit-rate APIs are fine.
bpy.ops.wm.usd_export(filepath="scene.usdc") # + usd_importRound-trip verified on device. The runtime pieces live inside the module
directory (bpy/lib/libusd_ms + bpy/usd_resources/{lib_usd,plugin_usd}); the
host app sets PXR_PLUGINPATH_NAME so the plugin registry resolves (CodeBench
does this automatically). MaterialX shader export is compiled in. Two
limitations: OpenUSD is built without its Python bindings, so a USDHook
subclass registered from a script never fires (basic import/export unaffected);
and exporting a scene that contains a baked fluid DOMAIN livelocks (the
export spins forever at low CPU) — delete/exclude the domain object or export
selected meshes only.
Render an animation straight to a video file. Blender 5.x gotcha: set
image_settings.media_type = 'VIDEO' before file_format = 'FFMPEG' — the
file-format enum only exposes the movie formats once the media type is video.
scene = bpy.data.scenes[0]
scene.render.image_settings.media_type = "VIDEO" # ← must come first
scene.render.image_settings.file_format = "FFMPEG"
scene.render.ffmpeg.format = "MPEG4" # container (.mp4)
scene.render.ffmpeg.codec = "H264" # see table below
scene.render.filepath = "anim" # ~/Documents, NOT /tmp
scene.frame_start, scene.frame_end = 1, 48
bpy.ops.render.render(animation=True) # → anim0001-0048.mp4Codecs verified on-device (iPad):
| Codec | Encoder | Notes |
|---|---|---|
H264 / H265 |
Apple VideoToolbox (hardware) | standard compact .mp4; 4:2:0, lossy |
MPEG4 |
native libavcodec | widely playable .mp4 |
FFV1 |
native libavcodec | lossless (use the MKV container) |
QTRLE / PNG / HUFFYUV |
native | lossless / intra-only |
iOS specifics, handled for you in the module:
- Encoding is single-threaded on iOS — ffmpeg's encoder worker threads abort on teardown on-device, so the movie codecs run on one thread (slower, stable).
- H.264/H.265 use the hardware VideoToolbox encoder (no
libx264is bundled); the module feeds it a software 4:2:0 frame, so the default lossless setting is high-quality lossy for those two — pickFFV1for true lossless. - Audio muxing: audaspace is now bundled, so scene-strip audio can mix into the render — not yet exercised on device; treat as experimental.
- Confirm with
bpy.app.build_options.codec_ffmpeg(True).
In the CodeBench app, saving a .blend auto-generates an interactive WebGL 3D
viewer in the preview pane — orbit / pinch-zoom / pan, plus a tap-toggle to the
photoreal Cycles render. You write no preview code:
bpy.ops.wm.save_as_mainfile(filepath="/path/scene.blend") # → live 3D previewThis is driven by two bundled modules:
codebench_blend_view.py— extracts evaluated geometry (modifiers applied), bakes it to a self-contained WebGL viewer (build_view(scene, …) → html).bpy/5.3/scripts/startup/codebench_blend_preview.py— a@persistentsave_posthandler that renders a Cycles still, builds the viewer, and ships it through CodeBench's preview channel.
Set CB_BLEND_NO_RENDER=1 to skip the still render for faster saves.
bpy.context.scene/.active_objectcan beNonein this headless build (and always inside handlers). Usebpy.data.scenes[0]/bpy.data.objects, and read modifier results via the evaluated depsgraph.bpy.data.objects[-1]is ordered by name, not creation. After a primitive-add op, grab the new object withbpy.context.active_object(or a before/afterset(bpy.data.objects.keys())diff) —[-1]returns the alphabetically-last object once the scene fills up.gpu.init()works — the headless build now has an offscreen Metal backend, so thegpumodule andBLENDER_EEVEErender on device. (Cycles GPU rendering still doesn't require it.)- glTF with Draco / MeshOptimizer works — the bridge dylibs are
cross-compiled for iOS and wrapped as frameworks;
export_scene.gltf(..., export_draco_mesh_compression_enable=True)compresses (~5× on a monkey) and reimports cleanly. - No
_multiprocessingon iOS — a stub ships sobpyimports cleanly.
bpy/__init__.so is ~231 MB, far over GitHub's 100 MB per-blob limit. Git LFS is
incompatible with SwiftPM's checkout
(swift-package-manager#5351),
so — exactly like libtorch_python.dylib — the binary ships LZMA-compressed:
Sources/Blender/
├── Blender.swift # BlenderLib.bootstrap()
├── bpy -> ../../app_packages/site-packages/bpy (scripts, tracked)
└── bpy_dylib/
└── __init__.so.applzma # Apple COMPRESSION_LZMA
The raw .so is git-ignored; rebuild the blob with
swift scripts/repack-bpy-so.swift after rebuilding bpy. Consumers decompress
once at launch:
import Blender
try BlenderLib.bootstrap() // materializes bpy/__init__.so into Caches/bootstrap() is idempotent (a stat() after the first run). See Blender.swift
for how the host app points Python's loader at BlenderLib.modulePath.
Cross-compiled with CMake's built-in iOS support (-DCMAKE_SYSTEM_NAME=iOS,
find-root mode, iphoneos SDK, deployment 16.4). Blender ships no cross-compile
support, so the host code generators (makesdna/makesrna/datatoc) are
harvested from the build, recompiled host-native, and re-imported as
IMPORTED targets. All ~30 dependencies (Embree, OIDN, OpenVDB, Alembic,
Bullet, Mantaflow, FFTW, GMP/manifold, OpenUSD, MaterialX, ceres, libsndfile,
fribidi, …) were rebuilt for iOS arm64 — recipes in the project's
blender_ios/ tree (build_*_ios.sh + the bpy_ios_source.patch and
bpy_ios_metal_gpu.patch, round-trip verified against upstream c9dd766c).
Remaining gaps vs the PyPI wheel — the honest final accounting:
- Cycles OSL — structurally impossible: OSL's shader JIT emits runtime machine code, which iOS's W^X policy forbids. Never closeable. (SVM shader nodes work; only the OSL script node is out.)
- OpenMP — perf-only, and not worth it: Blender's threading is TBB (which is on), and iOS ships no libomp (Homebrew's clashes on architecture).
- Rubberband — buildable but pointless here: it only feeds the VSE audio
time-stretch, and iOS's null audio device can't return the stretched
buffers anyway (
aud.Sound.buffer()spins).
build_hash — CLOSED (WITH_BUILDINFO=ON, device-verified): bpy.app
now reports a real git hash (c9dd766ce9c2) plus build_date,
build_commit_date, build_branch, build_platform, build_type,
build_system — the whole family was empty/Unknown before.
Eevee + the gpu module — the former "one structural gap" — now work via
the offscreen Metal backend.