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.

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.

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.

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.1.0.tar.gz (1.1 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.1.0-py3-none-any.whl (62.6 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: scenic_bpy-1.1.0.tar.gz
  • Upload date:
  • Size: 1.1 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.1.0.tar.gz
Algorithm Hash digest
SHA256 b02afeab6557a7d2cf1b8392055a6acfe661150e6621693e039e6acb8d3466ff
MD5 14a8a8519588911e623334a793edef6a
BLAKE2b-256 69a3bba7138269d6e1db1530f3711dcccb2b2c6b69e135628f8d3433f962d59d

See more details on using hashes here.

Provenance

The following attestation bundles were made for scenic_bpy-1.1.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.1.0-py3-none-any.whl.

File metadata

  • Download URL: scenic_bpy-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 62.6 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 38f95483358d99be6db87face583b2ab64b13c66ebd8e88a719a3aa557a41d5b
MD5 1e5e7474b633c8edc093fb66bb54f452
BLAKE2b-256 04203f907870a075f93ba623c0f9f447ebb93946f6de560568dd65dd42c99659

See more details on using hashes here.

Provenance

The following attestation bundles were made for scenic_bpy-1.1.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