jsblender
v0.0.4
Published
Modern, typesafe .blend file parser for Blender 5 and up.
Readme
jsblender
Modern, typesafe .blend file parser for Blender 5 and up. Works in Node, Bun, and the browser.
⚠️ Heavily vibe-coded. The code is not reviewed, will probably crash on edge-case .blend files, and the surface area exposed only covers what the author needed. Not recommended for anything important.
Status
- Decompresses zstd (Blender 3.0+ default) and reads already-uncompressed
.blendfiles. - Parses the modern 17-byte file header and the 32-byte block headers used by Blender 5.
- Reconstructs the SDNA (Structure DNA) schema and exposes typed struct layouts.
- Resolves Blender 5's reused-pointer quirk: the writer hands several ID datablocks the same
oldPtrfor their temporary buffers —jsblenderdisambiguates by file position.
Install
npm install jsblender
# or
bun add jsblenderESM-only. Node 18+, Bun, or any modern browser. The only runtime dependency is fzstd (pure JS).
Quick start
import { readFileSync } from 'node:fs'
import {
parseBlend,
extractScenes,
extractMeshes,
extractMaterials,
extractObjects,
extractLights,
extractCameras,
extractImages,
extractArmatures,
} from 'jsblender'
const blend = parseBlend(readFileSync('scene.blend'))
console.log(blend.header)
// { version: 5.01, pointerSize: 8, endianness: 'little', largeFormat: true, size: 17, versionString: '0501' }
for (const mesh of extractMeshes(blend)) {
console.log(mesh.name, mesh.vertexCount, mesh.faceCount, mesh.triangles.length / 3)
console.log('custom props:', mesh.customProperties)
}In the browser, get the bytes from a drop / file picker / fetch:
const buf = new Uint8Array(await file.arrayBuffer())
const blend = parseBlend(buf)API
parseBlend(input: Uint8Array | ArrayBuffer): BlendFileData
Decompresses (if needed), validates, and indexes the file. The returned object is what every extractor reads from:
interface BlendFileData {
header: BlendHeader // version, pointer size, endianness, large-format flag
sdna: SDNA // names, types, sizes, struct layouts
blocks: BlendBlock[] // every block in file order, last entry is ENDB
reader: StructReader // low-level cursor with pointer-resolution helpers
}Errors thrown:
Not a .blend file: unrecognised magic bytes— input does not start withBLENDER, zstd, or gzip magic.gzip-compressed .blend files are not supported in this runtime— legacy gzip-compressed file in a runtime withoutBun.gunzipSync. Re-save the file in Blender 3+ (zstd) or decompress upfront.No DNA1 block found in .blend file— file is truncated or corrupt.
extractMeshes(blend): Mesh[]
Returns one entry per ME datablock. Geometry comes from the new AttributeStorage (Blender 5) — positions, corner→vertex/edge indices, material indices, UV maps, vertex colour layers. Face data is presented in offset form (matching Blender's internal layout) and additionally triangulated for direct GPU upload.
interface Mesh {
name: string
vertexCount: number
edgeCount: number
faceCount: number
cornerCount: number
vertices: Float32Array // length = vertexCount * 3
vertexNormals: Float32Array // recomputed from face winding
faceNormals: Float32Array // length = faceCount * 3
faceOffsets: Uint32Array // length = faceCount + 1; loops for face i live in [offsets[i], offsets[i+1])
cornerVertices: Uint32Array // per-corner vertex index
cornerEdges?: Uint32Array // per-corner edge index, when stored
materialIndices: Uint32Array // length = faceCount; index into materialSlotNames
materialSlotNames: string[]
triangles: Uint32Array // triangulated face indices (fan from corner 0)
uvMaps: Record<string, Float32Array> // CD_PROP_FLOAT2 on CORNER domain
vertexColors: Record<string, Float32Array> // CD_PROP_COLOR (float4)
vertexByteColors: Record<string, Uint8Array> // CD_PROP_BYTE_COLOR (uchar4)
vertexGroupNames: string[]
dvert?: DeformVertex[] // per-vertex weights, when the mesh has any
attributes: Record<string, MeshAttributeRaw> // every attribute, including ones not surfaced above
}
interface DeformVertex {
totalWeight: number
weights: { groupIndex: number; weight: number }[]
}Every returned datablock also carries customProperties: Record<string, IDPropertyValue> and a sibling customPropertyTypes: Record<string, IDPropertyTypeName> — see Custom properties below.
extractMaterials(blend): Material[]
interface Material {
name: string
diffuse: [r, g, b, a] // float, linear
specular: [r, g, b]
metallic: number
roughness: number
hasNodeTree: boolean
shader?: ShaderGraph // present when nodes are used
customProperties: Record<string, IDPropertyValue>
}
interface ShaderGraph {
nodes: ShaderNode[]
principled?: PrincipledBSDF // distilled, when the graph has one
}
interface PrincipledBSDF {
nodeName: string
baseColor: [r, g, b, a]
metallic: number
roughness: number
ior: number
alpha: number
emissionColor: [r, g, b, a]
emissionStrength: number
baseColorImage?: string // name of an image bound via a Tex Image node
normalImage?: string
roughnessImage?: string
metallicImage?: string
}The full node graph is exposed in shader.nodes — each node lists its idname (ShaderNodeBsdfPrincipled, ShaderNodeTexImage, …), its inputs with their default-value sockets, and any incoming links. Anything beyond Principled BSDF you can pull yourself from there.
extractLights(blend): Light[]
interface Light {
name: string
type: 'point' | 'sun' | 'spot' | 'area'
color: [r, g, b]
energy: number
radius: number
spotSize?: number // spot only
spotBlend?: number
sunAngle?: number // sun only
areaShape?: 'square' | 'rectangle' | 'disk' | 'ellipse'
areaSize?: [x] | [x, y]
useNodes: boolean
customProperties: Record<string, IDPropertyValue>
}extractCameras(blend): Camera[]
interface Camera {
name: string
type: 'perspective' | 'orthographic' | 'panoramic'
lens: number // mm
sensorWidth: number
sensorHeight: number
sensorFit: 'auto' | 'horizontal' | 'vertical'
orthoScale: number
clipStart: number
clipEnd: number
shiftX: number
shiftY: number
customProperties: Record<string, IDPropertyValue>
}extractImages(blend): Image[]
interface Image {
name: string
filepath: string // e.g. "//tex.png" (relative) or an absolute path
source: 'file' | 'sequence' | 'movie' | 'generated' | 'viewer' | 'tiled'
generatedWidth: number
generatedHeight: number
packed?: Uint8Array // raw bytes of the embedded file, when packed
customProperties: Record<string, IDPropertyValue>
}extractScenes(blend): Scene[] and extractCollections(blend): Collection[]
interface Scene {
name: string
cameraObject?: string // active camera object name
frameStart: number
frameEnd: number
frameCurrent: number
fps: number // frs_sec / frs_sec_base
resolutionX: number
resolutionY: number
resolutionPercentage: number
rootCollection?: Collection
customProperties: Record<string, IDPropertyValue>
}
interface Collection {
name: string
objectNames: string[] // objects directly inside this collection
children: Collection[] // recursive
customProperties: Record<string, IDPropertyValue>
}extractScenes returns scenes with their full collection hierarchy. extractCollections returns every standalone GR datablock at the top level, also walkable.
extractObjects(blend): SceneObject[]
Every OB datablock — cameras, lamps, meshes, armatures, etc.
interface SceneObject {
name: string
type: number // see OB_TYPE
location: [x, y, z]
rotation: [x, y, z] // Euler XYZ, radians
scale: [x, y, z]
worldMatrix: Float32Array // float[16], row-major
dataName?: string // name of the linked ID datablock (e.g. the mesh)
parentName?: string
customProperties: Record<string, IDPropertyValue>
}
import { OB_TYPE } from 'jsblender'
// OB_TYPE.EMPTY = 0, MESH = 1, CURVE = 2, SURF = 3, FONT = 4, MBALL = 5,
// LAMP = 10, CAMERA = 11, SPEAKER = 12, LIGHTPROBE = 13,
// LATTICE = 22, ARMATURE = 25, GPENCIL = 26extractArmatures(blend): Armature[]
interface Armature {
name: string
bones: Bone[] // top-level (root) bones
customProperties: Record<string, IDPropertyValue>
}
interface Bone {
name: string
head: [x, y, z] // armature-space
tail: [x, y, z] // armature-space
roll: number // radians
length: number
armatureMatrix: Float32Array // 4x4 rest pose, row-major
children: Bone[]
}Modifiers (evaluateMesh)
extractMeshes returns each ME datablock as stored — no Subdiv, Mirror, Array, etc. evaluation. For renderers that need the visible geometry, jsblender ships a small modifier evaluator that supports Mirror and Array (the two cheapest geometry-generating modifiers).
import { evaluateMesh, evaluateAllMeshes, extractObjectModifiers } from 'jsblender'
const obj = extractObjects(blend).find(o => o.name === 'megaxe')!
const mesh = evaluateMesh(blend, obj) // Mesh with Mirror / Array applied
// Or batch:
const byObject = evaluateAllMeshes(blend) // Map<objectName, Mesh>
// Inspect a stack without evaluating:
const mods = extractObjectModifiers(blend).get('megaxe')
// [{ type: 'mirror', axisX: true, merge: true, tolerance: 0.001, ... },
// { type: 'array', count: 4, useRelativeOffset: true, ... }]Supported modifiers:
| Modifier | What jsblender does | Skipped |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| Mirror | Per-axis vertex duplication with reversed face winding. Spatial weld merges mirrored positions onto existing vertices within tolerance (handles seams-on-plane and stacked redundant mirrors). | mirror_ob frame, MOD_MIR_BISECT_*, UV swapping, vertex-group swap. |
| Array | Fixed-count duplication with combined constantOffset + relativeOffset × bbox. | FIT_LENGTH / FIT_CURVE, useObjectOffset, merge-between-copies, start/end caps. |
Other modifier types are returned as { type: 'unknown', typeCode } and pass the mesh through unchanged.
Custom properties (IDProperty)
Every datablock returned by an extract* function carries a
customProperties: Record<string, IDPropertyValue> field — Blender's user-defined
metadata, decoded into plain JS values.
type IDPropertyValue =
| string
| number
| boolean
| number[] // INT / FLOAT / DOUBLE array
| boolean[] // BOOLEAN array
| { [key: string]: IDPropertyValue } // GROUP (nested object)
| { __idRef: string | null } // ID reference (datablock by name)
| IDPropertyValue[] // IDPARRAYextract* functions surface only the user's ID.properties group; Blender's
internal system_properties are ignored.
const cube = extractMeshes(blend).find(m => m.name === 'Cube')
cube?.customProperties
// {
// myFloat: 1,
// myInteger: 1,
// myBoolean: true,
// myString: 'abc',
// myFloatArray: [1, 1, 1],
// myDataBlock: { __idRef: null },
// }INT and FLOAT collapse onto the same JS number, which matters when you want
to serialise back to a format that distinguishes them (e.g. emitting an INT
5 vs a FLOAT 5.0). Each datablock also carries a sibling
customPropertyTypes: Record<string, IDPropertyTypeName> keyed by the same
names, holding the original IDP_TYPE:
cube?.customPropertyTypes
// {
// myFloat: 'DOUBLE',
// myInteger: 'INT',
// myBoolean: 'BOOLEAN',
// myString: 'STRING',
// myFloatArray: 'ARRAY',
// myDataBlock: 'ID',
// }IDPropertyTypeName mirrors the keys of the exported IDP_TYPE constant —
'INT' | 'FLOAT' | 'DOUBLE' | 'BOOLEAN' | 'STRING' | 'ARRAY' | 'GROUP' | 'ID' | 'IDPARRAY'.
Nested groups show up as 'GROUP'; their inner types aren't recursed into.
For one-off lookups you can also call readCustomPropertyTypes(reader, idOffset)
directly.
Low-level access
If the typed extractors above don't cover what you need, drop down to the SDNA-driven reader. Every struct Blender exports is queryable by name, every field by name:
const { reader, sdna } = blend
const meshLayout = reader.layoutOf('Mesh') // SDNAStructLayout
const totvert = reader.fieldOf(meshLayout, 'totvert')
for (const block of blend.blocks) {
if (block.code !== 'ME') continue
const count = reader.readInt32(block.dataOffset + totvert.offset)
console.log('mesh has', count, 'vertices')
}Pointer fields point at other blocks. reader.blockAt(ptr, anchor?) resolves them, using anchor (the dereferencing block's dataOffset) as a tie-breaker when Blender reuses oldPtr values across writes:
const fMat = reader.fieldOf(meshLayout, 'mat')
const matsPtr = reader.readPointer(meshBlock.dataOffset + fMat.offset)
const matsBlock = reader.blockAt(matsPtr, meshBlock.dataOffset)The full SDNA is exposed too:
sdna.types // string[] every type name in the file
sdna.typeSizes // number[] byte size of each type
sdna.names // string[] every field name (raw form, e.g. "*next", "head[3]")
sdna.parsedNames // ParsedFieldName[] same names decomposed
sdna.structs // SDNAStruct[] type-index + fields
sdna.layouts // SDNAStructLayout[] precomputed offsets and sizes
sdna.structIndexByType // Map<string, number>For the modern attribute-storage layout there are additional helpers:
import { ATTR_TYPE, ATTR_DOMAIN, readAttributeStorage, readAttributeAsFloats } from 'jsblender'
const mesh = reader.layoutOf('Mesh')
const fStorage = reader.fieldOf(mesh, 'attribute_storage')
const attrs = readAttributeStorage(reader, meshBlock.dataOffset, fStorage)
const pos = attrs.find(a => a.name === 'position' && a.dataType === ATTR_TYPE.FLOAT3)
const positions = pos ? readAttributeAsFloats(reader, pos) : undefinedRuntime support
- Node 18+ — works directly with the published ESM build. Verified on Node 25.
- Bun — primary development target. The test suite runs under
bun test. - Browsers — see
example/in this repo for a Next.js drop-zone that parses files entirely client-side.
The library makes no node:* imports, so bundlers (Vite, esbuild, Webpack, Next, etc.) target browsers without polyfills.
Caveats
- Only Blender 5+ files have been validated. Legacy 12-byte headers are recognised, but the per-mesh layout assumes the modern
AttributeStoragepipeline; pre-3.0 files are out of scope. - Big-endian and 32-bit pointer code paths exist in the block walker but have no real-world test coverage.
- Vertex normals are recomputed from face winding because Blender no longer writes them by default. For custom split normals, read the
corner_normalattribute viamesh.attributes. - Shader node trees, modifiers, animations, and the bGPencil format are not parsed.
Development
This is a Bun monorepo with two workspaces:
library/ the jsblender package
example/ Next.js drop-zone demobun install
bun run --filter library build # tsup -> library/dist
bun run --filter library test # bun test against simple.blend
bun run --filter example dev # Next.js dev server on portless
bun run all # format:check + lint + typecheck + warden + test