Skip to content

Instantly share code, notes, and snippets.

@RednibCoding
Last active January 5, 2025 11:49
Show Gist options
  • Save RednibCoding/a3a3a236ea2b286ccc05a1873ffb07fb to your computer and use it in GitHub Desktop.
Save RednibCoding/a3a3a236ea2b286ccc05a1873ffb07fb to your computer and use it in GitHub Desktop.
StudioModel Data (SMD) Format Documentation

SMD 3D Model Format Documentation

Overview

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.

Key Features

  • 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.

Extensions

  • .smd: Standard model and animation data.
  • .vta: Vertex animation data.

File Structure

SMD files consist of the following blocks, which appear in a specific order:

Header

The file starts with the version identifier:

version 1

Nodes

Defines the skeletal hierarchy.

nodes
<int|ID> "<string|Bone Name>" <int|Parent ID>
end

Example:

nodes
0 "root" -1
1 "child" 0
end

Skeleton

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

Triangles

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

Python Scripts

Loader and Viewer Example

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()

Exporter/Importer Example

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()

References

Appendix

Output of the loader/viewer:

image

SMD file used

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

Texture file used

cube

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment