← Index · Prev · Next →

03 — The Blender Add-on (editor half)

A Blender 4.2+/5.x extension (blender_addon/, installed from babylon_level_kit_extension.zip). It owns authoring, GUID assignment, and the export that produces the two artifacts.

Prefabs (linked .blend files)

Each separate .blend library file can act as a prefab: link it into the level (File → Link…), place instances, then Make Library Override on the collection or objects you need to customize. Component fields on overrides are editable because the add-on marks Object.bjs_components and nested PropertyGroups as library-overridable (core/props.py). Export flattens linked instances into the level artifacts like any other object; duplicate GUIDs across instances are re-issued on export. Full workflow: Prefabs.

Where the UI lives

Split by object vs scene:

Exposed lists (@exposed({ type: "list" }))

Each list field in a SCRIPT component renders as its own collapsible box (BJSExposedVar.show_expanded) — independent of the component's header collapse — with a triangle toggle and an N item(s) summary when folded. Grow a list three ways: the + button (bjs.list_add), a typed count field (the var's list_count get/set resizes the collection), and — for entity lists — an Add Selected button (bjs.list_add_selected) that drops every selected viewport object into the list at once, skipping the list's own object and any duplicates. Pin the inspector first so picking those objects doesn't switch the panel away.

Package map

PackageSingle purpose
__init__.pyextension registration order + dev-reload of submodules
core/pure helpers, nothing registered: ids.py (GUIDs + VISIBLE_KEY + CAST_SHADOWS_KEY), script_parse.py (@exposed / @inputMap regex parsing), props.py (library-override property wrappers), prop_copy.py (generic RNA copy/snapshot + remove_collection_item)
components/per-object data model: component.py (BJSComponent, trigger/click events), exposed_vars.py, object_settings.py, clipboard.py, constants.py (all enums)
materials/per-Material NME overrides: properties.py (BJSNmeTexture, BJSNmeInput, BJSNmeGradient), nme_scan.py + nme_inputs.py + nme_gradients.py (enumerate texture blocks, inspector inputs, and gradient blocks from JSON), nme_textures.py (detect/decode/extract embedded textures)
scene/scene-wide render settings on Scene.bjs_scene (settings.py, environment.py for World Output texture discovery, atmosphere.py for physical sky, post_processing.py for the nested post block)
input_actions/the Input Actions asset end-to-end: properties.py, defaults.py, serialize.py, operators.py
export/everything that writes files: level.py (orchestrator), components.py (registry dispatch + reference walker) + component_serializers.py (the SERIALIZERS registry — one function per component type, axis conversion here), materials.py (NME JSON + texture patch), datablocks.py, animation.py, scene.py, atmosphere.py, post_processing.py, assets.py (copy_asset), validate.py, live_link.py
operators/component verbs (components.py), script pick + Sync (scripts.py), Validate + Export (export_ops.py), NME scan/extract/Launcher (material_ops.py)
ui/all panels and menus: view3d_panels.py, material_panels.py, scene_panels.py, post_panels.py, input_panel.py, component_draw.py (header + dispatch) + component_bodies.py (the BODY_DRAWERS registry — one inspector body per component type), common.py, menus.py
viewport/GPU overlays: collider_preview.py (collider wireframe), cog_preview.py (dynamic rigid-body CoM cross), probe_preview.py (reflection-probe influence volume)

Rule of thumb: core/, components/, scene/, input_actions/ own *data*; export/ owns *output*; operators/ owns *behavior*; ui/ owns *presentation*.

Contributor rules — persisted enums and @exposed types

Blender stores EnumProperty values as integer indices inside .blend files, not as the string id. Reordering or inserting items in the middle of an enum tuple silently remaps every saved value.

@exposed variable kinds (vtype) are stored as string ids ('ENTITY', 'COLOR', …) on BJSExposedVar, like elem_type already was. Sync from the script source sets the type — artists never pick it from a dropdown. VAR_TYPES is UI metadata only and can be reordered freely.

NME input kinds (value_type) on BJSNmeInput follow the same rule: string ids set by Scan NME from the JSON block type. NME_INPUT_TYPES is UI metadata only.

Collision layer picks (collision_layer) store the layer name as a string; the dropdown (collision_layer_select) is a get/set proxy over the scene layer list, so removing or reordering scene layers never retargets saved components. Renaming a layer means components pointing at the old name must be re-picked — Validate flags layer names that are no longer in the scene list.

Component kinds (comp_type) are still an EnumProperty. Never insert new entries in the middle of COMPONENT_TYPES — always append at the end. The Add Component menu uses ADD_COMPONENT_MENU instead, so new types can appear under the right section without shifting saved indices.

After changing @exposed fields in code, hit per-component Sync Variables before re-exporting. After changing an NME JSON, hit Scan NME on affected materials.

Registries that must stay in sync across Python and TypeScript (COMPONENT_TYPES, SERIALIZERS, BODY_DRAWERS, …) are checked by node scripts/check-component-types.mjs — run it when you add a component kind. Enum tuples are not auto-checked; treat reordering as a breaking change.

Contributor rules — editing collection properties

Blender forbids bpy_prop_collection.remove() on the linked base rows of library-override (prefab) data: you can insert and remove local rows, but the linked rows cannot be touched. Never call .remove() directly on addon collections; use core/prop_copy.py:remove_collection_item(collection, index), which is best-effort — it removes the row when allowed and returns False without mutating anything when Blender refuses. Do not "fix" a refused remove with clear() + rebuild: clear() only drops the local rows, so re-adding duplicates every linked row (this caused wheel/body fields and Sync results to double up on prefab cars).

For the same reason, code that reconciles a collection against a fresh source — e.g. sync_exposed_vars — must update rows in place (match by name, edit fields, add missing) rather than clear-and-rebuild, and should de-duplicate defensively (keep the first row per name) to repair files a previous bug already doubled. The module also owns the generic RNA deep-copy used by the component clipboard (copy_props) and the virtual-property skip list (VIRTUAL_PROPS: get/set proxies like e_val, list_count, collision_layer_select that must never be copied directly).

The export pipeline (what "Export Level" does)

  1. Validate (export/validate.py:validate_scene) — warnings surface in the report.

  2. GUID pass (export/level.py) — ensure_object_id (core/ids.py) for every object that needs one, including referenced objects (entity fields, camera targets, trigger/click targets, constraint targets) so references always resolve. Duplicated GUIDs (copy-pasted objects) are re-issued.

  3. Stamp glTF extras_stamp_gltf_extras writes transient bjs_visible: 0 for viewport-hidden objects (visible_get(), including collection hierarchy) and bjs_cast_shadows: 0 when ray-visibility Shadow is off (visible_shadow); cleared after the glb is written.

  4. Write the glb via Blender's glTF exporter (+Y-up, use_renderable=True skips render-disabled objects, GUIDs + visibility/shadow-casting flags in node extras).

  5. Build the manifest — per renderable entity: serialize_components (viewport-hidden entities also get "visible": false in the manifest) (export/components.py dispatching through the SERIALIZERS registry in export/component_serializers.py; one dict per component), plus auto-derived light / camera / animation blocks; plus the scene block (export/scene.py); plus the top-level "debug" flag (Debug Build checkbox, owned by export/live_link.py). Schema "version": 4 — checked at load by ValidateManifest and kept in sync with the runtime by scripts/check-component-types.mjs.

  6. Copy side filesbegin_asset_export() then copy_asset / save_image_asset (export/assets.py): World environment texture when find_world_env_node (scene/environment.py) finds one on the active World Output chain (sanitized filename under env/); rotationY from a Mapping node on the texture Vector input (-Mapping.Z for Z-up → Y-up); Default Environment sets useDefault in the manifest instead of a file (with intensity / rotationY from environment_intensity / environment_rotation_y on Scene.bjs_scene); Show Skybox / Skybox Ignores FogcreateSkybox / skyboxIgnoreFog (forced off when Atmosphere is on — the addon renders the sky); color-grading LUTs → post/, audio → audio/, GUI layouts → gui/, particle systems → particles/ (JSON via export_particle_system; optional Scan Textures + per-slot image picks patch ParticleTextureSourceBlock URLs in the exported JSON), node materials → materials/ (NME JSON via export_node_material; each distinct NME source file is copied once per export, then patched — shared JSON across several Blender materials is safe; Scan NME on the Properties › Material › Babylon panel lists texture rows, inspector-visible InputBlock parameters, and inspector-visible GradientBlock color stops; embedded slots with data: / base64String stay in the JSON unless you pick an external override, which strips the embed and writes manifest textures[]; authored inputs and gradients write manifest inputs[] / gradients[]), 3D button images → gui/. Each export pass resets path reservations so re-exports overwrite the same sanitized name (Live Link safe). _2, _3, … suffixes apply only when two different sources collide on the same sanitized name in a single export.

  7. Remember the path for Live Link.

Object visibility (eye vs camera vs shadow)

Blender togglePropertyExport behavior
Eye (viewport)visible_get() falseIncluded in glb; "visible": false in manifest + bjs_visible in node extras → invisible at load
Collider › Make Invisiblecollider_make_invisibleIncluded in glb; COLLIDER row makeInvisible: trueHideEntityNode at load (physics unchanged)
Ray Visibility › Shadowvisible_shadow falseIncluded in glb; bjs_cast_shadows: 0 in node extras → receive-only at load (no shadow casting)
Camera (render)hide_renderExcluded from glb and manifest (_is_renderable)

Axis conversions (Blender Z-up → Babylon Y-up)

Done at export in export/component_serializers.py, so the runtime receives Babylon-space values unchanged: vectors (x, y, z) → (x, z, −y); sizes swap y/z; quaternions keep w with vector part converted; constraint axis enum X/Y/Z → unit vectors via the same map. CUSTOM constraints also export six per-axis rows (axes[]: mode, min/max, stiffness/damping). viewport/collider_preview.py, viewport/cog_preview.py, and viewport/probe_preview.py draw raw Blender values, so the viewport previews match the exported body, CoM, and probe influence volume.