SMD (StudioModel Data) is a plain-text file format used for 3D models, primarily in Valve's Source and GoldSrc engines. SMD is also known to be used by Sauerbraten and third party tools for The Sims and Mount & Blade. It stores geometry, skeletal animations, and optionally vertex animations in a simple, easy-to-parse structure.
- Reference Models: Default pose with geometry and bone hierarchy.
- Skeletal Animations: Keyframes for bone positions and rotations.
- Vertex Animations: Morph targets for facial expressions or flex animations (via
.vta
files). - UV Mapping: Basic support for texturing with a single set of UV coordinates.
.smd
: Standard model and animation data..vta
: Vertex animation data.
SMD files consist of the following blocks, which appear in a specific order:
The file starts with the version identifier:
version 1
Defines the skeletal hierarchy.
nodes
<int|ID> "<string|Bone Name>" <int|Parent ID>
end
Example:
nodes
0 "root" -1
1 "child" 0
end
Specifies bone positions and rotations for each animation frame.
skeleton
time <int>
<int|Bone ID> <float|PosX PosY PosZ> <float|RotX RotY RotZ>
end
Example:
skeleton
time 0
0 0 0 0 0 0 0
1 0 2 0 0 0 0
time 1
1 0 3 0 0 0 0
end
Defines geometry and texture mapping. Each triangle has:
- Material name: A string specifying the texture or material.
- Vertices: Position, normals, UVs, and bone weights.
triangles
<material>
<int|Bone> <float|PosX PosY PosZ> <float|NormX NormY NormZ> <float|U V> <int|Bone Links> <int|Bone ID> <float|Weight>
end
Example:
triangles
example_texture.bmp
0 0 0 0 0 0 1 0.0 0.0 1 0 1.0
1 1 0 0 0 0 1 1.0 0.0 1 1 0.5
2 0 1 0 0 0 1 0.0 1.0 1 0 0.5
end
This script parses an SMD file and displays the 3D model using Ursina.
from ursina import *
# Note: you need to install Ursina: pip install ursina
def load_smd(file_path):
"""
Parse an SMD file and return nodes, skeleton, and grouped triangles by texture,
even when a texture is reused in multiple sections.
"""
nodes = {}
skeleton = {}
textures = {} # Maps each texture to its associated triangles
vertices = [] # Global vertices for consistent indexing
uvs = []
indices = []
try:
with open(file_path, "r") as f:
lines = f.readlines()
current_section = None
current_time = None
current_texture = None # Track the currently active texture
for line_no, line in enumerate(lines, start=1):
line = line.strip()
# Ignore empty lines
if not line:
continue
# Section markers
if line == "nodes":
current_section = "nodes"
elif line == "skeleton":
current_section = "skeleton"
elif line == "triangles":
current_section = "triangles"
elif line == "end":
current_section = None
# Parse nodes section
elif current_section == "nodes":
try:
parts = list(filter(None, line.split(" ")))
if len(parts) != 3:
raise ValueError("Malformed node entry. Expected 3 values (ID, name, parent ID).")
node_id = int(parts[0])
node_name = parts[1].strip('"')
parent_id = int(parts[2])
nodes[node_id] = (node_name, parent_id)
except (ValueError, IndexError) as e:
print(f"Error in 'nodes' section at line {line_no}: {e}")
# Parse skeleton section
elif current_section == "skeleton":
if line.startswith("time"):
try:
current_time = int(line.split()[1])
skeleton[current_time] = []
except ValueError as e:
print(f"Error in 'skeleton' section at line {line_no}: Invalid time value. {e}")
else:
try:
parts = list(filter(None, line.split(" ")))
if len(parts) != 7:
raise ValueError("Expected 7 float values (bone ID, PosX, PosY, PosZ, RotX, RotY, RotZ).")
bone_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z = map(float, parts)
skeleton[current_time].append((int(bone_id), pos_x, pos_y, pos_z, rot_x, rot_y, rot_z))
except (ValueError, IndexError) as e:
print(f"Error in 'skeleton' section at line {line_no}: {e}")
# Parse triangles section
elif current_section == "triangles":
if not line[0].isdigit(): # Texture name
current_texture = line
if current_texture not in textures:
textures[current_texture] = {"vertices": [], "uvs": [], "indices": []}
else:
try:
parts = list(map(float, line.split()))
if len(parts) >= 9:
x, y, z = parts[1:4]
u, v = parts[7:9]
# Add the vertex to the global list and to the current texture
textures[current_texture]["vertices"].append((x, y, z))
textures[current_texture]["uvs"].append((u, v))
global_index = len(vertices) # Use global indexing for consistency
textures[current_texture]["indices"].append(global_index)
vertices.append((x, y, z))
uvs.append((u, v))
indices.append(global_index)
except (ValueError, IndexError) as e:
print(f"Error in 'triangles' section at line {line_no}: {e}")
return {"nodes": nodes, "skeleton": skeleton, "textures": textures}
except Exception as e:
print(f"Error loading SMD file: {e}")
return None
class SMDViewer(Entity):
def __init__(self, smd_path, **kwargs):
super().__init__(**kwargs)
# Load SMD data
smd_data = load_smd(smd_path)
if not smd_data:
print("Failed to load SMD file.")
return
nodes = smd_data["nodes"]
skeleton = smd_data["skeleton"]
textures = smd_data["textures"]
# Log parsed data for debugging purposes
print(f"Nodes: {nodes}")
print(f"Skeleton: {skeleton}")
print(f"Textures: {list(textures.keys())}")
# Use the first texture for rendering
first_texture = next(iter(textures.keys()), None)
if first_texture:
texture_data = textures[first_texture]
self.model = Mesh(vertices=texture_data["vertices"],
triangles=texture_data["indices"],
uvs=texture_data["uvs"],
mode='triangle')
try:
self.texture = Texture(first_texture)
except Exception as e:
print(f"Error loading texture '{first_texture}': {e}")
else:
print("No textures found in the SMD file.")
return
# Apply collision for interaction (optional)
self.collider = 'mesh'
# Ursina app
app = Ursina()
# Create an editor grid
grid = Entity(model=Grid(20, 20), scale=20, color=color.gray, rotation=(90, 0, 0))
# Load SMD model
viewer = SMDViewer('cube.smd', scale=0.5)
# Add controls
EditorCamera()
# Run the app
app.run()
Note: This heavely depends on how the data is structured in the source program you are writing an importer/exporter for (e.g. Blender, 3DS Max etc).
Blender 3.6 SMD Exporter/Importer
How to install:
- save the code below to a file called
smd-exporter-importer.py
- In Blender go to
Edit
>Preferences
>Add-ons
- click install
- select the
smd-exporter-importer.py
- activate the add on
bl_info = {
"name": "SMD Importer and Exporter",
"author": "Michael Binder aka RednibCoding",
"version": (1, 0, 0),
"blender": (3, 6, 0),
"location": "File > Import/Export",
"description": "Import and Export selected Mesh as SMD file",
"warning": "",
"wiki_url": "",
"tracker_url": "",
"category": "Import-Export",
}
import bpy
from mathutils import Vector
import bpy_extras.io_utils
# Importer Class
class ImportSMD(bpy.types.Operator, bpy_extras.io_utils.ImportHelper):
"""Import SMD File"""
bl_idname = "import_mesh.smd"
bl_label = "Import SMD"
bl_options = {'REGISTER', 'UNDO'}
# File extension filter
filename_ext = ".smd"
filter_glob: bpy.props.StringProperty(
default="*.smd",
options={'HIDDEN'},
)
def execute(self, context):
"""Execute the import process"""
try:
self.import_smd(self.filepath)
self.report({'INFO'}, f"SMD imported successfully from {self.filepath}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Import failed: {e}")
return {'CANCELLED'}
def import_smd(self, file_path):
import os
with open(file_path, "r") as smd_file:
lines = smd_file.readlines()
vertices = []
normals = []
uvs = []
indices = []
current_texture = None
materials = {}
in_triangles = False
# Base directory of the SMD file
base_dir = os.path.dirname(file_path)
for line in lines:
line = line.strip()
if not line:
continue
if line == "triangles":
in_triangles = True
continue
elif line == "end":
in_triangles = False
continue
if in_triangles:
if not line[0].isdigit(): # Texture name
current_texture = line
if current_texture not in materials:
materials[current_texture] = len(materials)
else:
try:
parts = list(map(float, line.split()))
x, y, z = parts[1:4]
nx, ny, nz = parts[4:7]
u, v = parts[7:9]
vertex = Vector((x, y, z))
normal = Vector((nx, ny, nz))
uv = (u, v)
if vertex not in vertices:
vertices.append(vertex)
normals.append(normal)
uvs.append(uv)
indices.append(vertices.index(vertex))
except (ValueError, IndexError) as e:
print(f"Error parsing triangle data: {e}")
# Debugging output
print(f"Vertices: {len(vertices)}, Normals: {len(normals)}, UVs: {len(uvs)}, Indices: {len(indices)}")
# Create the mesh
mesh = bpy.data.meshes.new("SMD_Mesh")
obj = bpy.data.objects.new("SMD_Object", mesh)
bpy.context.scene.collection.objects.link(obj)
# Add vertices, faces, and UVs
try:
mesh.from_pydata(vertices, [], [indices[i:i + 3] for i in range(0, len(indices), 3)])
except Exception as e:
print(f"Error creating mesh: {e}")
return
mesh.update()
# Add UVs
uv_layer = mesh.uv_layers.new(name="UVMap")
uv_index = 0 # Track the UV index for the current triangle
for poly in mesh.polygons:
for loop_index in poly.loop_indices:
if uv_index < len(uvs):
uv_layer.data[loop_index].uv = uvs[uv_index]
uv_index += 1
else:
print(f"Warning: UV index out of range. UV count: {len(uvs)}, Loop index: {loop_index}")
# Add material
mat = bpy.data.materials.new(name="SMD_Material")
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
if current_texture:
texture_path = os.path.join(base_dir, current_texture)
try:
tex_image = mat.node_tree.nodes.new('ShaderNodeTexImage')
tex_image.image = bpy.data.images.load(texture_path, check_existing=True)
mat.node_tree.links.new(bsdf.inputs['Base Color'], tex_image.outputs['Color'])
except Exception as e:
print(f"Warning: Unable to load texture '{texture_path}': {e}")
obj.data.materials.append(mat)
mesh.create_normals_split()
loop_normals = []
for poly in mesh.polygons:
for loop_index in poly.loop_indices:
vertex_index = mesh.loops[loop_index].vertex_index
loop_normals.append((normals[vertex_index].x, normals[vertex_index].y, normals[vertex_index].z))
mesh.normals_split_custom_set(loop_normals)
# Enable smooth shading for all faces
for poly in mesh.polygons:
poly.use_smooth = True
mesh.update()
# Exporter Class
class ExportSMD(bpy.types.Operator, bpy_extras.io_utils.ExportHelper):
"""Export selected mesh to SMD"""
bl_idname = "export_mesh.smd"
bl_label = "Export SMD"
filename_ext = ".smd"
filter_glob: bpy.props.StringProperty(
default="*.smd",
options={'HIDDEN'},
)
use_selection: bpy.props.BoolProperty(
name="Export Selected Only",
description="Export only selected objects",
default=True,
)
scale: bpy.props.FloatProperty(
name="Scale",
description="Scale factor for the exported model",
default=1.0,
min=0.001,
max=1000.0,
)
def execute(self, context):
try:
objects = context.selected_objects if self.use_selection else context.scene.objects
meshes = [obj for obj in objects if obj.type == 'MESH']
if not meshes:
self.report({'ERROR'}, "No valid mesh objects to export!")
return {'CANCELLED'}
with open(self.filepath, "w") as smd_file:
smd_file.write("version 1\n")
smd_file.write("nodes\n")
smd_file.write(" 0 \"root\" -1\n")
smd_file.write("end\n")
smd_file.write("skeleton\n")
smd_file.write("time 0\n")
smd_file.write(" 0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000\n")
smd_file.write("end\n")
smd_file.write("triangles\n")
for obj in meshes:
mesh = obj.to_mesh(preserve_all_data_layers=True, depsgraph=context.evaluated_depsgraph_get())
mesh.calc_loop_triangles()
materials = mesh.materials or ["default_material"]
for tri in mesh.loop_triangles:
mat_index = tri.material_index
material = materials[mat_index] if materials else None
if material and material.node_tree:
for node in material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
texture_name = node.image.name
break
else:
texture_name = f"{material.name}.png"
else:
texture_name = "default_material.png"
smd_file.write(f"{texture_name}\n")
for loop_index in tri.loops:
vert = mesh.vertices[mesh.loops[loop_index].vertex_index]
uv = mesh.uv_layers.active.data[loop_index].uv if mesh.uv_layers.active else (0.0, 0.0)
smd_file.write(f" 0 {vert.co.x * self.scale:.6f} {vert.co.y * self.scale:.6f} {vert.co.z * self.scale:.6f} ")
smd_file.write(f"{vert.normal.x:.6f} {vert.normal.y:.6f} {vert.normal.z:.6f} ")
smd_file.write(f"{uv.x:.6f} {uv.y:.6f}\n")
smd_file.write("end\n")
self.report({'INFO'}, f"SMD exported successfully to {self.filepath}")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, f"Export failed: {e}")
return {'CANCELLED'}
# Menu Functions
def menu_func_import(self, context):
self.layout.operator(ImportSMD.bl_idname, text="SMD Importer (.smd)")
def menu_func_export(self, context):
self.layout.operator(ExportSMD.bl_idname, text="SMD Exporter (.smd)")
# Registration
def register():
bpy.utils.register_class(ImportSMD)
bpy.utils.register_class(ExportSMD)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
def unregister():
bpy.utils.unregister_class(ImportSMD)
bpy.utils.unregister_class(ExportSMD)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
if __name__ == "__main__":
register()
Output of the loader/viewer:
Just copy the contents below, create a file called cube.smd
and paste it into it.
version 1
nodes
0 "root" -1
end
skeleton
time 0
0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
end
triangles
cube.png
0 -2.0 -2.0 -2.0 0.0 0.0 -1.0 0.0 0.0
0 2.0 -2.0 -2.0 0.0 0.0 -1.0 1.0 0.0
0 -2.0 2.0 -2.0 0.0 0.0 -1.0 0.0 1.0
cube.png
0 2.0 -2.0 -2.0 0.0 0.0 -1.0 1.0 0.0
0 2.0 2.0 -2.0 0.0 0.0 -1.0 1.0 1.0
0 -2.0 2.0 -2.0 0.0 0.0 -1.0 0.0 1.0
cube.png
0 -2.0 -2.0 2.0 0.0 0.0 1.0 0.0 0.0
0 -2.0 2.0 2.0 0.0 0.0 1.0 1.0 0.0
0 2.0 -2.0 2.0 0.0 0.0 1.0 0.0 1.0
cube.png
0 2.0 -2.0 2.0 0.0 0.0 1.0 0.0 1.0
0 -2.0 2.0 2.0 0.0 0.0 1.0 1.0 0.0
0 2.0 2.0 2.0 0.0 0.0 1.0 1.0 1.0
cube.png
0 -2.0 -2.0 -2.0 -1.0 0.0 0.0 0.0 0.0
0 -2.0 2.0 -2.0 -1.0 0.0 0.0 1.0 0.0
0 -2.0 -2.0 2.0 -1.0 0.0 0.0 0.0 1.0
cube.png
0 -2.0 -2.0 2.0 -1.0 0.0 0.0 0.0 1.0
0 -2.0 2.0 -2.0 -1.0 0.0 0.0 1.0 0.0
0 -2.0 2.0 2.0 -1.0 0.0 0.0 1.0 1.0
cube.png
0 2.0 -2.0 -2.0 1.0 0.0 0.0 0.0 0.0
0 2.0 -2.0 2.0 1.0 0.0 0.0 1.0 0.0
0 2.0 2.0 -2.0 1.0 0.0 0.0 0.0 1.0
cube.png
0 2.0 -2.0 2.0 1.0 0.0 0.0 1.0 0.0
0 2.0 2.0 2.0 1.0 0.0 0.0 1.0 1.0
0 2.0 2.0 -2.0 1.0 0.0 0.0 0.0 1.0
cube.png
0 -2.0 -2.0 -2.0 0.0 -1.0 0.0 0.0 0.0
0 -2.0 -2.0 2.0 0.0 -1.0 0.0 1.0 0.0
0 2.0 -2.0 -2.0 0.0 -1.0 0.0 0.0 1.0
cube.png
0 2.0 -2.0 -2.0 0.0 -1.0 0.0 0.0 1.0
0 -2.0 -2.0 2.0 0.0 -1.0 0.0 1.0 0.0
0 2.0 -2.0 2.0 0.0 -1.0 0.0 1.0 1.0
cube.png
0 -2.0 2.0 -2.0 0.0 1.0 0.0 0.0 0.0
0 2.0 2.0 -2.0 0.0 1.0 0.0 1.0 0.0
0 -2.0 2.0 2.0 0.0 1.0 0.0 0.0 1.0
cube.png
0 2.0 2.0 -2.0 0.0 1.0 0.0 1.0 0.0
0 2.0 2.0 2.0 0.0 1.0 0.0 1.0 1.0
0 -2.0 2.0 2.0 0.0 1.0 0.0 0.0 1.0
end
- free create texture from is from here: https://www.pinterest.com/pin/554927985308068811/
Just save it next to the
cube.smd
file and rename it tocube.png
.