A FastAPI-style framework for building Blender scenes from typed, composable components.
Project description
scenic
A FastAPI-style framework for building Blender scenes with bpy: declare typed, composable
components; scenic compiles them into a .blend. No UI, no clicks — just Python.
The scene above is examples/lighthouse.py — a raw-mesh island,
component-composed tower, driver-rotated beams, geonode-scattered rocks, gradient-sky
backdrop, and an NLA-looped buoy. One spec file, rendered headless.
A full game environment: examples/village/ is a reusable asset
Library (seeded parametric houses, windmill, well, stalls, trees, fences, torches) composed
onto a terrain heightfield with geonode scattering — 278 objects sharing 53 meshes and
22 materials, and scenic build -o village.glb exports the whole set engine-ready at ~300 KB.
The stress test: examples/city/ — 2,985 objects across 8 structured
files. 46 cars and 36 pedestrians run on modulo drivers, traffic signals cycle, an
elevated train loops, a physics truck scatters crates at the construction site, and
every window is a shader. Compiles in 0.49 s; a no-op rebuild reconciles in 0.13 s;
the whole city shares 24 meshes.
| Procedural rust (shader DSL) | Geometry-node scatter |
|---|---|
| Driver-powered orbit | IK arm + auto-skinning |
|---|---|
Every image above is the output of a spec file in examples/, rendered headless
with scenic render — no Blender UI was opened.
from scenic import Scenic, Depends, Field
from scenic.geometry import Cube, Bevel
from scenic.shading import PrincipledBSDF
from scenic.staging import Scene, Camera, Sun
app = Scenic()
@app.material()
def steel(roughness: float = Field(0.3, ge=0, le=1)):
return PrincipledBSDF(metallic=1.0, roughness=roughness)
@app.asset()
def crate(size: float = 1.0, mat=Depends(steel)):
return Cube(size=size).modify(Bevel(width=0.03, segments=3)).shade(mat)
@app.scene()
def main(box=Depends(crate)):
return Scene(
objects=[box],
camera=Camera(location=(4, -4, 3), look_at=(0, 0, 0)),
lights=[Sun(energy=3, rotation=(50, 0, 30))],
)
scenic build scenes.py -o out/main.blend # compile to a .blend
scenic render scenes.py -o out/main.png -p size=2 # render a still, override params
scenic docs scenes.py --json # component catalog as JSON Schemas
How it works
Builders never call bpy. They return specs — immutable Pydantic models. The compiler
(scenic.compile, the only module that imports bpy) realizes specs into datablocks. That split
means params are validated before anything mutates a scene, tests run without Blender, scenes
serialize to JSON, and equal specs dedup to shared datablocks (two crates with the same steel
share one material).
See DESIGN.md for the full architecture and roadmap.
Install
pip install -e ".[dev]" # framework + tests (no Blender needed)
pip install -e ".[blender]" # + the bpy wheel for headless build/render
Requires Python 3.11+. Runs headless via the bpy wheel or inside Blender 4.2+.
Shader node DSL
Materials are node graphs written as expressions — sockets pipe with >>, scalar arithmetic
becomes Math nodes, and the whole graph is still an immutable, JSON-serializable spec:
from scenic.shading import Bump, ColorRamp, Mix, NoiseTexture, PrincipledBSDF
@app.material()
def rust_metal(rust: float = Field(0.5, ge=0, le=1)):
steel = PrincipledBSDF(base_color=(0.55, 0.56, 0.58), metallic=1.0, roughness=0.35)
corrosion = PrincipledBSDF(
base_color=NoiseTexture(scale=24).fac >> ColorRamp(stops=((0.3, (0.22, 0.06, 0.02)), (0.7, (0.45, 0.18, 0.06)))),
roughness=0.85,
normal=NoiseTexture(scale=30, detail=8).fac >> Bump(strength=0.4),
)
mask = NoiseTexture(scale=6, detail=6).fac >> ColorRamp(stops=((0.35, "black"), (0.65, "white")))
return Mix(fac=mask * rust, a=steel, b=corrosion)
Animation
Keyframes are authored on a Timeline and attached to objects with .animate().
.ease() shapes the motion into the keys you just set — what animators mean:
from scenic.animation import Timeline
@app.animation()
def drop_and_squash(height: float = 4.0, land: int = 18):
t = Timeline()
t.at(1).loc(z=height)
t.at(land).loc(z=0.5).ease("BOUNCE", "EASE_OUT") # arrive with a bounce
t.at(land + 3).scale(1.2, 1.2, 0.6) # squash
t.at(land + 9).scale(1, 1, 1).ease("BACK", "EASE_OUT")
return t
ball = Sphere(radius=0.5).animate(drop_and_squash(height=6))
scenic render examples/bounce.py -o out/bounce.mp4 # full frame range
Rotations are authored in degrees. The compiler targets Blender 5.0's slotted
actions with a 4.2 legacy fallback. The pip bpy wheel can't encode video, so
.mp4 output renders PNG frames and assembles them with system ffmpeg.
Live reload
scenic watch scenes.py -o out/main.blend # rebuilds on every save
Builds are incremental: every managed datablock carries a content-hash key, and rebuilds only create/remove what changed (a no-op rebuild of a 30-object scene is ~4 ms; identical geometry and materials are shared datablocks).
For a live viewport inside Blender, install scenic/addon.py (requires
pip install scenic-bpy into Blender's Python): a Scenic tab appears in the
3D-viewport sidebar with Build Once and Watch — only the managed "Scenic"
collection is ever touched.
Libraries
Library is the APIRouter analog — a mountable pack of components, distributable as a
pip package:
from scenic import Library
parts = Library("scifi")
@parts.asset()
def corridor(...): ...
app.include(parts) # available as "scifi.corridor"
Drivers
Scripted expressions on any channel; variables reference other objects by name:
moon = IcoSphere(radius=0.2).named("moon").drive("location", "cos(frame / 24) * 3", index=0)
sat = Sphere(radius=0.1).drive("location", "moon_z + 2", index=2,
variables=(Var("moon_z", target="moon", transform="LOC_Z"),))
Kit ecosystem
One layout engine, interchangeable asset packs. settlement() in
examples/biomes/ dresses any Library that satisfies a
six-component contract (dwelling / landmark / centerpiece / flora /
light_source / clutter). Both images below are the same function call —
only the kit, ground function, and lighting differ:
settlement(medieval, rolling_hills, ...) |
settlement(scifi, cratered_flats, ...) |
|---|---|
The medieval kit adapts the existing village pack without modifying it — the way you'd wrap a pip-installed asset library.
And packs really are pip packages. scenic-pack-bazaar lives in
packs/ as its own distribution; installing it makes a third world
from the same call — discovered by name, no import required:
pip install scenic-pack-bazaar
scenic packs # bazaar v0.1.0 — dwelling, landmark, fountain, ...
app.include("bazaar") # resolved via the scenic.libraries entry point
Packs register one line in their pyproject —
[project.entry-points."scenic.libraries"] bazaar = "scenic_pack_bazaar:bazaar" —
and become visible to scenic packs, to app.include("<name>"), and to LLM
agents through the MCP list_packs tool.
Making your own is one command:
scenic new-pack props # publishable skeleton: entry point, example asset,
pip install -e scenic-pack-props # QC contact-sheet scene, spec-side tests
scenic packs # ...and it's discoverable
Physics, tracking, and interop
Rigid bodies are one chained call; the camera can track any named object; the classic kinematic-throw handover is two Timeline keys:
from scenic import Physics
crates = [Cube(size=0.6).at(0, 0, 0.3 + i * 0.61).rigid(mass=1) for i in range(6)]
throw = Timeline()
throw.at(1).loc(0, -7, 2.4).prop("rigid_body.kinematic", 1.0).ease("CONSTANT")
throw.at(12).loc(0, -1.6, 1.9).ease("LINEAR")
throw.at(13).prop("rigid_body.kinematic", 0.0) # release: solver inherits velocity
ball = Sphere(radius=0.45).animate(throw).rigid(mass=25, shape="SPHERE")
Scene(
objects=[*crates, ball, Plane(size=40).rigid(passive=True)],
camera=Camera(location=(7, -7, 3.5), track="ball"), # follows the sim
physics=Physics(gravity=(0, 0, -9.81), substeps=15),
)
The compiler fills the physics cache by stepping frames before stills render, so
scenic render --frame 40 of a simulation is deterministic. Object constraints
(TrackTo, CopyLocation, CopyRotation) resolve targets by object name.
scenic build -o scene.usdc exports USD alongside glTF and .blend.
Diffing scenes
Preview exactly what a rebuild would touch — before building:
scenic diff scenes.py --base HEAD~1 # vs the scene file at a git rev (no bpy)
scenic diff scenes.py --against out/main.blend # vs the tags in a built .blend
scenic diff scenes.py -p rows=5 --base HEAD --exit-code # CI gate
Reconciler plan keys are content hashes, so the diff is exact: per-object create/keep/remove, plus material/mesh/world/render deltas.
Linting scenes
Because scenes are pure data, sanity checks run without Blender or a render:
scenic lint scenes.py # exit 1 on findings — CI-friendly
lint computes world-space bounding boxes from the specs and flags floating
objects, group parts nothing connects to, siblings stacked on a shared origin
(rotated parts that should radiate from a pivot), below-ground and zero-scale
objects. Objects whose location is driven or keyframed are exempted.
For LLM agents (MCP)
scenic speaks the Model Context Protocol — plug it into Claude Code, or any MCP-capable harness:
{ "mcpServers": { "scenic": { "command": "scenic", "args": ["mcp"] } } }
Seven tools cover the whole agent loop: list_components (JSON-Schema catalog),
get_scene_spec (resolve a scene to its editable JSON template), validate and
lint (no Blender needed), diff (what would rebuild), build
(.blend/.glb/.usd), and render_preview — a fast EEVEE thumbnail returned as an
MCP image for visual feedback in seconds. Failures return structured envelopes
({"type", "message", "errors", "hint"}), never tracebacks. Scenes round-trip
as JSON: agents can edit a spec and rebuild it without executing any code
(scenic build scene.json works from the CLI too, and scenic render --preview
gives the same fast feedback).
Serving scenes over HTTP
pip install "scenic-bpy[serve]"
scenic serve scenes.py --port 8000
Every @app.scene() becomes POST /scenes/{name}/render (PNG) and
POST /scenes/{name}/build?format=blend|glb, with request validation and OpenAPI
docs generated from the same schemas the CLI uses. scenic build -o scene.glb
exports glTF directly too.
Rigging
Declarative bones, IK, and constraints; meshes bind to a parent armature with automatic weights:
from scenic.rigging import Armature, Bone, IK, Rig, chain
@app.rig()
def arm_rig():
upper = Bone("upper", head=(0, 0, 0), tail=(0, 0.06, 0.5))
fore = Bone("fore", head=(0, 0.06, 0.5), tail=(0, 0, 0.95), parent="upper", connected=True)
ctrl = Bone("ctrl", head=(0, 0, 0.95), tail=(0, 0.22, 0.95), deform=False)
return Rig(bones=(upper, fore, ctrl),
constraints=(IK(bone="fore", target="ctrl", chain_count=2),))
armature = Armature(arm_rig()).animate(wave()).with_children(
tube().skinned() # ARMATURE_AUTO weights
)
Pose bones key through the same Timeline: t.at(16).bone("ctrl").loc(x=0.45).
chain("upper", "fore", lengths=(0.5, 0.45)) lays connected runs head-to-tail.
NLA
Layer or sequence clips instead of baking one action:
obj.play(walk_cycle(), at=1, repeat=4).play(blink(), at=10, track="face", blend="ADD")
Geometry nodes
Procedural scattering and instancing with the same piping DSL, attached as a modifier:
from scenic.geometry import GeoNodes
from scenic.geonodes import (DistributePoints, GeoInput, IcoSphereMesh, InputGeometry,
InstanceOnPoints, RandomVector, SetMaterial)
rocks = (
InputGeometry()
>> DistributePoints(density=GeoInput("density", 0.8), seed=3)
>> InstanceOnPoints(
instance=IcoSphereMesh(radius=0.14, subdivisions=1) >> SetMaterial(material=rock_mat()),
scale=RandomVector(min=(0.4, 0.4, 0.3), max=(1.6, 1.6, 1.0)).value,
)
)
field = Plane(size=14).modify(GeoNodes(rocks, inputs={"density": 2.0}))
GeoInput sockets become real tree inputs: modifiers sharing a graph share one
node tree and vary only their values (tweakable in Blender's modifier panel too).
humanoid(height=1.8) from scenic.rigging gives a ready 19-bone biped with IK
arms/legs and hand/foot controls.
Status (v0.6)
| Area | State |
|---|---|
Typed components, Depends, validation |
✅ |
| Primitives, modifiers, camera/lights/world/render | ✅ |
Shader node DSL (>> piping, textures, ramps, mix, bump) |
✅ |
| Timeline animation DSL, animation components, mp4 render | ✅ |
| Incremental reconciler, shared mesh/material datablocks | ✅ |
Drivers, Library/include, scenic serve, glTF export |
✅ |
| Rigging (bones, chains, IK/constraints, auto-skinning), NLA | ✅ |
| Geometry nodes (scatter/instance DSL), humanoid preset | ✅ |
scenic lint / scenic diff — bpy-free static checks & rebuild preview |
✅ |
Geo-tree GeoInput params (shared trees, per-modifier values) |
✅ |
| Rigid-body physics, object constraints, camera tracking, USD export | ✅ |
Kit ecosystem: settlement() contract over interchangeable Libraries |
✅ |
scenic build / render / docs / watch / serve, in-Blender addon |
✅ |
The original DESIGN.md roadmap is complete. Works on Blender/bpy 4.2 through 5.0 (socket renames and the slotted-action API are handled with fallbacks).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file scenic_bpy-1.6.0.tar.gz.
File metadata
- Download URL: scenic_bpy-1.6.0.tar.gz
- Upload date:
- Size: 1.5 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
48cece87f572f2edda615cb1814388fef8b50fa2aba5d9c5f82be7cb2bfc15a7
|
|
| MD5 |
3cce9303ee95d9ee6e9bd82c606bcb55
|
|
| BLAKE2b-256 |
32cbfb72a66da6b7d06fcbc17131cfde3aaef4c2fcb0219985b1564fce99eadf
|
Provenance
The following attestation bundles were made for scenic_bpy-1.6.0.tar.gz:
Publisher:
publish.yml on satyam-fp/scenic-bpy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
scenic_bpy-1.6.0.tar.gz -
Subject digest:
48cece87f572f2edda615cb1814388fef8b50fa2aba5d9c5f82be7cb2bfc15a7 - Sigstore transparency entry: 2071668324
- Sigstore integration time:
-
Permalink:
satyam-fp/scenic-bpy@5862bf932a2f4d7d02f3fef0be83edb3f9e7048d -
Branch / Tag:
refs/tags/v1.6.0 - Owner: https://github.com/satyam-fp
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@5862bf932a2f4d7d02f3fef0be83edb3f9e7048d -
Trigger Event:
push
-
Statement type:
File details
Details for the file scenic_bpy-1.6.0-py3-none-any.whl.
File metadata
- Download URL: scenic_bpy-1.6.0-py3-none-any.whl
- Upload date:
- Size: 75.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7e29fc1446923a5c2c2598a904db92957ef73112ce492cecb119427b1c39bdf9
|
|
| MD5 |
8c582056be30ecc21fc68426a2f09336
|
|
| BLAKE2b-256 |
4bb11e00ec7e3b28d707257fc8a249fe345f74c19ca47b201e82385dfddbbbc7
|
Provenance
The following attestation bundles were made for scenic_bpy-1.6.0-py3-none-any.whl:
Publisher:
publish.yml on satyam-fp/scenic-bpy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
scenic_bpy-1.6.0-py3-none-any.whl -
Subject digest:
7e29fc1446923a5c2c2598a904db92957ef73112ce492cecb119427b1c39bdf9 - Sigstore transparency entry: 2071668333
- Sigstore integration time:
-
Permalink:
satyam-fp/scenic-bpy@5862bf932a2f4d7d02f3fef0be83edb3f9e7048d -
Branch / Tag:
refs/tags/v1.6.0 - Owner: https://github.com/satyam-fp
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@5862bf932a2f4d7d02f3fef0be83edb3f9e7048d -
Trigger Event:
push
-
Statement type: