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.

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 Web export 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).

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 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:

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:

# 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 Compressed Textures (BC / ASTC / ETC2) 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 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.

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.