Skip to main content

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.

lighthouse at dusk

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.

village at golden hour

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.

city aerial

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.

Production quality is in reach — the realism stack (denoising, AgX color management, true displacement, full Principled: subsurface/coat/sheen/anisotropy, Fresnel/AO/pointiness nodes, HDRI lighting) drives Cycles to product-viz and VFX-environment grade:

Product viz: pocketwatch.py VFX environment: canyon.py
pocket watch slot canyon

Left: anisotropic brass, coated enamel, refractive glass dome, wave-displacement knurling, macro DOF. Right: the strata are TRUE displacement — geometry, not texture — with AO crevice darkening and a real sky HDRI.

Procedural rust (shader DSL) Geometry-node scatter
rust crates meadow
Driver-powered orbit IK arm + auto-skinning
orbit arm wave

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, ...)
medieval settlement scifi colony

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:

desert souk

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

scenic_bpy-1.7.0.tar.gz (1.7 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

scenic_bpy-1.7.0-py3-none-any.whl (77.2 kB view details)

Uploaded Python 3

File details

Details for the file scenic_bpy-1.7.0.tar.gz.

File metadata

  • Download URL: scenic_bpy-1.7.0.tar.gz
  • Upload date:
  • Size: 1.7 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for scenic_bpy-1.7.0.tar.gz
Algorithm Hash digest
SHA256 bfc94f67a89ef6116c2695b5afdd8ffaa2cfdaa054f2630d591593287763436c
MD5 fde4717872a402b919a9a258e3105f60
BLAKE2b-256 343f6654884f56947507f90e6bd0d0ff4099a26c1c37afd9c764c698ddee270e

See more details on using hashes here.

Provenance

The following attestation bundles were made for scenic_bpy-1.7.0.tar.gz:

Publisher: publish.yml on satyam-fp/scenic-bpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file scenic_bpy-1.7.0-py3-none-any.whl.

File metadata

  • Download URL: scenic_bpy-1.7.0-py3-none-any.whl
  • Upload date:
  • Size: 77.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for scenic_bpy-1.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 930e03689ed11871ddd58cf03589c2de9ab3760843525da099d723b28abb627d
MD5 f73473ff6f71dd895dc5fd0ded5fc6dd
BLAKE2b-256 8b9b3ce250ec18f33dc78e6991e9cc17eb8004a03d35e6fb3500700c9a9fc2c7

See more details on using hashes here.

Provenance

The following attestation bundles were made for scenic_bpy-1.7.0-py3-none-any.whl:

Publisher: publish.yml on satyam-fp/scenic-bpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page