# Blender to SimVX (glTF) SimVX imports glTF 2.0 scenes with a single call: `import_gltf` turns a `.gltf` file into a ready-to-use `Node3D` hierarchy with meshes, PBR materials, lights, cameras, skeletons, and skeletal animation clips. Blender ships a first-class glTF exporter, so the Blender-to-SimVX pipeline is: export from Blender, call `import_gltf`, add the returned node to your scene. ```python from simvx.graphics import import_gltf model = import_gltf("assets/ship.gltf") model.name = "Ship" self.add_child(model) ``` The same call works on desktop (Vulkan) and in the web runtime (WebGPU); see {ref}`web-notes` for the browser subset. A missing or unreadable file logs an error and returns an empty `Node3D` instead of raising, so a scene with a broken asset still runs. A complete runnable viewer is at `examples/features/3d/model_viewer.py` (Khronos DamagedHelmet) and a skinned-animation viewer at `examples/features/3d/animated_model.py` (Khronos Fox). ## Recommended export settings In Blender: File > Export > glTF 2.0. Settings that matter to SimVX: | Exporter setting | Recommended | Why | | --- | --- | --- | | Format | **glTF Separate** (`.gltf` + `.bin` + textures) | Loads on desktop AND in the web runtime, and `simvx cook` can walk the external references. `.glb` loads on desktop only. | | Transform > +Y Up | **On** (default) | glTF and SimVX share the same conventions (right-handed, Y-up, -Z forward), so no axis fix-ups happen at import. | | Include > Data > Punctual Lights | On, if you author lights in Blender | Exports `KHR_lights_punctual`; imports as SimVX light nodes. | | Include > Data > Cameras | On, if you author cameras in Blender | Imports as `Camera3D`. | | Mesh > Apply Modifiers | On | SimVX imports the evaluated mesh only. | | Mesh > Normals | On | Missing normals shade flat black. | | Mesh > Tangents | **On when using normal maps** | Exported tangents drive the high-quality tangent-space normal-mapping path. Without them the renderer falls back to screen-space derivative tangents (works, slightly lower quality). | | Mesh > Attributes / Vertex Colors | Optional | `COLOR_0` is imported and uploaded with the mesh (see {ref}`what-is-imported`). | | Compression (Draco) | **Off** | `KHR_draco_mesh_compression` is not supported; compressed meshes import empty. | | Animation | On, for rigged models | Skinned (armature) actions import as skeletal clips. Object-level animation does not import (see {ref}`limitations`). | (what-is-imported)= ## What is imported | glTF | SimVX | | --- | --- | | Node hierarchy + names | `Node3D` tree, names preserved; parent/child structure intact | | Node TRS or `matrix` transforms | `position`, `rotation` (`Quat`), `scale`; matrix nodes are decomposed | | Mesh primitives | `MeshInstance3D`; a multi-material mesh imports every primitive (extra primitives become identity-transform children) | | `POSITION`, `NORMAL`, `TEXCOORD_0`, indices | Core mesh streams | | `TANGENT` | Tangent-space normal mapping (pipeline variant enabled per mesh) | | `COLOR_0`, `TEXCOORD_1` | Imported and uploaded in the mesh's extras stream; not yet consumed by the built-in PBR shader | | PBR metallic-roughness materials | `simvx.core.Material` (see the mapping below) | | Alpha modes `OPAQUE` / `BLEND` / `MASK` | `blend="opaque"` / `"alpha"` / `"cutoff"` with the file's `alphaCutoff` | | `doubleSided` | `Material(double_sided=True)` | | `KHR_lights_punctual` | `DirectionalLight3D` / `PointLight3D` / `SpotLight3D` | | Cameras (perspective) | `Camera3D` (`yfov` radians -> `fov` degrees, `znear`/`zfar` -> `near`/`far`) | | `KHR_texture_transform` | `Material.uv_offset` / `uv_scale` / `uv_rotation` (one transform per material; the base-colour map's transform wins) | | `KHR_materials_emissive_strength` | Folded into the material's emissive intensity | | Skins (joints + inverse bind matrices) | `Skeleton` attached to the skinned `MeshInstance3D` | | Skinned animations | `SkeletalAnimationClip` list on the imported root | ### Material mapping (Principled BSDF) Blender's Principled BSDF exports to glTF PBR metallic-roughness, which maps directly onto {doc}`Material `: | Principled BSDF / glTF | `Material` | | --- | --- | | Base Color factor / texture | `colour` / `albedo_map` | | Metallic, Roughness | `metallic`, `roughness` (packed `metallicRoughnessTexture` -> `metallic_roughness_map`) | | Normal Map node | `normal_map` (export **Tangents** for best quality) | | Emission Color / Emission Strength sockets | `emissive_colour` + `emissive_strength`, or `emissive_map` for textured emission | | Occlusion (exporter's glTF material output group) | `ao_map` | | Alpha (with the material's blend/clip mode) | `blend="alpha"` or `blend="cutoff"` + `alpha_cutoff` | | Backface Culling off | `double_sided=True` | Emissive follows the glTF formula within what the additive shader term supports: a zero `emissiveFactor` (the spec default) disables the emissive texture; a factor without a texture becomes a constant emissive colour whose intensity carries `KHR_materials_emissive_strength`. A non-unit factor or strength combined with a texture cannot be expressed and the texture is kept unscaled. ### Lights glTF cone angles are radian half-angles and import converted to SimVX's degree half-angles; `range` is wired when present. glTF stores photometric intensities (candela for point/spot, lux for directional); SimVX converts them to its linear `Light3D.intensity` on import. The conversion inverts Blender's default ("Standard") export math (the CIE peak luminous efficacy 683 lm/W, plus `1/(4*pi) sr` for point and spot lamps), then anchors Blender's factory-default lamps onto the engine's nominal intensity `1.0`: | Blender default lamp | glTF value | Imported `intensity` | | --- | --- | --- | | Sun, 1.0 W/m^2 | 683 lux | 1.0 | | Point, 1000 W | ~54351 cd | ~1.0 | | Spot, 1000 W | ~54351 cd | ~1.0 | So a Blender scene lit with default-strength lamps imports at a sensible brightness, and any lamp round-trips to its Blender-native energy scaled to engine units. Files that instead carry raw glTF units (e.g. a literal `intensity` of `1` cd) are physically very dim and will import correspondingly faint, which is correct: those numbers are photometric, not engine multipliers. ### Cameras Perspective cameras import fully. Orthographic cameras import as a default perspective `Camera3D` with a logged warning. An imported camera does not become current automatically; activate it like any other camera. ## Skeletal animation The importer attaches the parsed `Skeleton` to each skinned `MeshInstance3D` and hangs the baked clips off the imported root. Drive them with an `AnimationPlayer`: ```python from simvx.core import AnimationPlayer from simvx.graphics import import_gltf model = import_gltf("assets/fox.gltf") self.add_child(model) skeleton = next( n.skeleton for n in model.walk() if getattr(n, "skeleton", None) ) player = AnimationPlayer(skeleton=skeleton) for clip in getattr(model, "_skeletal_clips", []): player.add_clip(clip) player.play("Run", loop=True) self.add_child(player) ``` See `examples/features/3d/animated_model.py` for the full pattern, including clip switching from UI. ## Cooking textures for shipping For shipped builds, convert your PNG/JPG textures to GPU block-compressed KTX2 and pre-transcode them offline so load time does zero transcode work: ```sh # 1. Author a universal UASTC .ktx2 per texture (once) toktx --uastc --genmipmap --t2 brick.ktx2 brick.png # 2. Reference the .ktx2 from the material / glTF image URI # 3. Cook the scene for your shipping target simvx cook ship.gltf -o cooked/ # desktop: explicit BC7 simvx cook ship.gltf -o cooked/ --target astc4x4 # mobile ``` A `.gltf` input carries every externally referenced buffer and image across (relative layout preserved, so `import_gltf("cooked/ship.gltf")` works as-is), transcoding each UASTC `.ktx2` and copying everything else unchanged. Web exports should keep the original UASTC sources: the browser transcodes per-device. See {doc}`compressed_textures` for the full authoring, fallback, and per-platform matrix. Blender exports PNG/JPG textures; the `toktx` step is how `.ktx2` enters the pipeline. Reference the `.ktx2` by its plain image URI: the glTF `KHR_texture_basisu` extension wrapper is not parsed (see below), but the texture manager dispatches on the `.ktx2` suffix, so a direct URI loads through the full compressed-texture path. (web-notes)= ## Web export notes `import_gltf` is backend-agnostic: the same call runs under Pyodide with textures streamed through the resource channel. The browser runtime uses a dependency-free parser that covers the common export shape (`.gltf` JSON + external `.bin` + image files, or inline `data:` URIs), including skins and skeletal animation. It does not read `.glb`, interleaved vertex buffers, or sparse accessors, so export **glTF Separate** for web targets and bundle the `.gltf`, `.bin`, and texture files alongside the game. (limitations)= ## Current limitations - **Draco / meshopt**: `KHR_draco_mesh_compression` and `EXT_meshopt_compression` are not supported. Export uncompressed. - **`KHR_texture_basisu`** is not parsed; reference `.ktx2` files by plain image URI instead (loads by suffix, desktop and web). - **`.glb`**: desktop only (via `pygltflib`); the web parser and `simvx cook` need `.gltf` + external files (`cook` copies a `.glb` unchanged without cooking its embedded images). - **Object animation**: only skinned (armature joint) channels import as clips; TRS animation of non-joint nodes is dropped. Morph targets / shape keys are not supported. - **Other material extensions** (transmission, clearcoat, sheen, IOR, specular, volume, iridescence) are ignored; the core PBR metallic-roughness set imports. - **One UV set per material**: every map samples `TEXCOORD_0` (`textureInfo.texCoord` selectors are ignored). `TEXCOORD_1` and `COLOR_0` are imported and uploaded but not yet consumed by the built-in PBR shader. - **One UV transform per material**: `KHR_texture_transform` applies material-wide, taken from the base-colour map first. - **Negative or near-zero scale** components are not applied to imported nodes (mirrored geometry should be baked into the mesh). - **Light units** are converted from glTF photometric values (lux / candela) to the engine's linear `intensity`, anchored on Blender defaults (see Lights above). - **Sparse accessors** are not supported on either parser backend.