Beispiel #1
0
 public void AddBonePose(SmdBonePose bonePose)
 {
     ExplicitBonePoses.Add(bonePose);
     ExplicitBonePoseByBoneName[bonePose.Bone.Name] = bonePose;
 }
Beispiel #2
0
        public static PacCustomAnimation Smd2Pac(SmdData untouchedSmdData,
                                                 List <string> ignoreBones,
                                                 int optimizeLevel,
                                                 Dictionary <string, Tuple <Vector3, Vector3> > boneFixups,
                                                 SmdData subtractionBaseSmd,
                                                 int subtractionBaseFrame,
                                                 bool hideWarnings,
                                                 out SmdData subtractedSmdData)
        {
            PacCustomAnimation pacAnim = new PacCustomAnimation();

            // Work on a copy of the SmdData so we can do pose subtraction nondestructively
            SmdData smdData = untouchedSmdData.Clone();


            ///// Find frame for base pose subtraction
            SmdTimelineFrame subtractionBasePoseFrame = null;

            if (subtractionBaseSmd != null)
            {
                // Validate the user's chosen frame number
                foreach (SmdTimelineFrame frame in subtractionBaseSmd.Timeline.ExplicitFrames)
                {
                    if (frame.FrameTime == subtractionBaseFrame)
                    {
                        subtractionBasePoseFrame = frame;
                        break;
                    }
                }
                if (subtractionBasePoseFrame == null)
                {
                    throw new Exception("No frame with \"time " + subtractionBaseFrame + "\" exists within the subtraction base SMD.");
                }
            }


            ///// Main animation data
            pacAnim.TimeScale = smdData.Timeline.ExpectedFrameRate; // Overall animation playback rate

            // The first smd frame with have an extremely high pac3 "FrameRate" due to the wacky animation rules of pac3
            // All subsequent frames with have a "FrameRate" derived from the time gap between them and the previous frame
            Print("- Processing animation frames...", 1);
            float lastSmdFrameTime = 0;

            for (int i = 0; i < smdData.Timeline.ExplicitFrames.Count; i++)
            {
                // Finalize the SMD frame data before translating it to the pac3 anim format
                SmdTimelineFrame smdFrame = smdData.Timeline.ExplicitFrames[i];
                smdFrame.BakeFrame();

                if (subtractionBasePoseFrame != null)
                {
                    /* Pose subtraction
                     *
                     *   SMD animations are all relative in parent bone space, but they are relative to 0,0,0 in both translation and rotation.
                     *   This means that every SMD animation is implicitly responsibly for maintaining the shape of the model's skeleton.
                     *   In other words, SMD animation is NOT relative to the model's bind pose, but rather includes the bind pose + the actual animation composited together.
                     *
                     *   This becomes a problem in-engine, since PAC3 custom animations are relative to either the model's bind pose or the currently playing sequence.
                     *   In other words, this means all PAC3 custom anims are additive animations. So if we feed PAC3 a normal SMD animation, things will look very ugly.
                     *   The solution is to "subtract" the desired animation from the model's bind pose, thus turning it into an additive animation that plays nice with PAC3.
                     *
                     *   Studiomdl can also do this, via the `subtract` part of the $animation command. The compiled MDL can then be decompiled with Crowbar to get the subtracted SMD.
                     *   In my testing, I have found this always adds a -90 Y rotation to the root bone. I'm not sure why that happens, and whether it's a studiomdl or Crowbar bug.
                     *   Valve has kept the core level of the Source engine's math code very secret, including studiomdl, unfortunately.
                     *   I did find a copy of the 2004 Episode 1 and 2007 SDK, though, which includes all the relevant code for subtracting animations - most crucially, it revealed Valve's inconsistencies in coordinate order that I was missing beforehand.
                     */

                    // Copy the frame and subtract the base pose frame from it for all bones
                    foreach (SmdBonePose subtractedBonePose in smdFrame.ExplicitBonePoses)
                    {
                        SmdBonePose subtractionBaseBonePose = null;
                        if (subtractionBasePoseFrame.ExplicitBonePoseByBoneName.TryGetValue(subtractedBonePose.Bone.Name, out subtractionBaseBonePose))
                        {
                            // Translation subtraction
                            // This is as straightforward as it can possibly be
                            subtractedBonePose.Position -= subtractionBaseBonePose.Position;

                            // Rotation subtraction
                            // This is much more interesting. Basic idea: r = q * p^-1

                            // The process here is identical to how studiomdl does it, but without the singularity/rounding/precision error that causes strange offsets
                            // Turn the SMD's yaw pitch roll into a quat (these are QAngles, NOT RadianAngles!)
                            Vector3     baseYPR = new Vector3(subtractionBaseBonePose.Rotation.Y, subtractionBaseBonePose.Rotation.Z, subtractionBaseBonePose.Rotation.X);
                            Vector3     destYPR = new Vector3(subtractedBonePose.Rotation.Y, subtractedBonePose.Rotation.Z, subtractedBonePose.Rotation.X);
                            VQuaternion baseRot = VQuaternion.FromQAngles(baseYPR);
                            VQuaternion destRot = VQuaternion.FromQAngles(destYPR);
                            // Undo the base animation, then apply the destination animation
                            Vector3 differenceRangles = VQuaternion.SMAngles(-1, baseRot, destRot); // RadianAngles?
                            // X is pitch, Y is yaw, Z is roll
                            subtractedBonePose.Rotation.X = differenceRangles.Z * 1;
                            subtractedBonePose.Rotation.Y = differenceRangles.X * 1;
                            subtractedBonePose.Rotation.Z = differenceRangles.Y * 1;

                            // In Source:
                            // - Pitch is rot X
                            // - Yaw is rot Y
                            // - Roll is rot Z
                            // But not always! Because it's Valve!
                            // Which is why we have to swizzle differenceRangles back into a QAngle, because SMAngles doesn't produce a proper QAngle!
                        }
                        else
                        {
                            if (hideWarnings)
                            {
                                Print("- [WARNING] Frame " + smdFrame.FrameTime + " subtraction: Bone " + subtractedBonePose.Bone.Name + " is not present in the subtraction base pose frame.", 1);
                                // Pose subtraction should really only be be done on sequences that have identical skeletons with every bone posed
                            }
                        }
                    }
                }

                // Frame duration
                PacFrame pacFrame = new PacFrame();
                if (i == 0)
                {
                    pacFrame.FrameRate = 999; // We want to complete this frame as fast as possible since there's no way to change the bind pose in pac3 custom animations
                }
                else
                {
                    /* SMD frame "time" is in fps. So if one frame has time 0 and the next has time 10, that's a gap of 10 *frames*.
                     * The actual time an SMD frame will take is defined by the `fps` option in the $sequence command.
                     * In comparison, pac3 has no concept of fps and instead sets frame interpolation time purely in seconds
                     * Every custom animation frame in pac3 is innately 1 second long and pac3 individually scales the playback rate of each frame to achieve different length frames
                     * To avoid making the ugly pac3 behavior even uglier, we will use pac3's "TimeScale" property to simply transform this 1 second long unit frame time into a 1/30s or 1/60s or 1/whatever unit.
                     */

                    float deltaSmdFrameCount = smdFrame.FrameTime - lastSmdFrameTime; // How many SMD "frames" it should take to interpolate between the two frames
                    pacFrame.FrameRate = 1.0f * deltaSmdFrameCount;                   // How many units this frame should take (natively seconds)
                }

                // Frame bone poses
                foreach (SmdBonePose smdBonePose in smdFrame.ExplicitBonePoses)
                {
                    if (ignoreBones.Contains(smdBonePose.Bone.Name))
                    {
                        continue;
                    }

                    PacBonePose pacBonePose = new PacBonePose();

                    /* Just like QAngles and RAngles, coordinates are not consistent either because Valve is Valve
                     *
                     * SMD coordinate system:
                     *   +X: North on the compass.
                     *   +Y: East on the compass.
                     *   +Z: Up.
                     *
                     *   - With your RIGHT hand, stick out your thumb, index finger, and middle finger in a kind of L shape.
                     *   - All 3 digits should form right angles with each other.
                     *   - Your index finger is +X, your thumb is +Y, and your middle finger is +Z.
                     *   - Twist your hand so your *index finger* is pointing directly forward in front of you and your middle finger is pointing at the sky.
                     *
                     * Source engine coordinate system:
                     *   +X: East on the compass. Moving forward (W) ingame moves you +X.
                     *   +Y: North on the compass. Moving left (A) ingame moves you +Y.
                     *   +Z: Up.
                     *
                     *   - With your LEFT hand, stick out your thumb, index finger, and middle finger in a kind of L shape.
                     *   - All 3 digits should form right angles with each other.
                     *   - Your index finger is +X, your thumb is +Y, and your middle finger is +Z.
                     *   - Twist your hand so your *thumb* is pointing at your monitor and your middle finger is pointing at the sky.
                     *
                     * Conversion from SMD to engine: Swap X and Y, then negate the new X.
                     *   - This is "correct" (for direct bone manipulation as intended), but does not work with pac3.
                     *
                     * Conversion from SMD to pac3: Swap X and Y, then negate both.
                     *   - This is because for some bizarre reason, pac uses an unorthodox coordinate space that assumes +Y is *south* on the compass.
                     *   - The various pac3 elements that manipulate bone position blindly negate the "MR" value (Y translation), so we have to adhere to that.
                     */

                    // Translation (including coordinate system conversion via swizzled x and y)
                    pacBonePose.MF = smdBonePose.Position.Y * -1;
                    pacBonePose.MR = smdBonePose.Position.X * -1;
                    pacBonePose.MU = smdBonePose.Position.Z * 1;

                    // Rotation
                    pacBonePose.RF = MathHelper.ToDegrees(smdBonePose.Rotation.X) * 1;
                    pacBonePose.RR = MathHelper.ToDegrees(smdBonePose.Rotation.Y) * 1;
                    pacBonePose.RU = MathHelper.ToDegrees(smdBonePose.Rotation.Z) * 1;

                    // Fixup translation + rotation
                    // These are optional constant transforms that are added to the bone pose every frame.
                    // Useful for fixing skeleton alignment, especially for `subtracted` SMDs created by studiomdl + Crowbar, which typically add an erraneous -90 Y rotation to the root bone.
                    Tuple <Vector3, Vector3> boneFixup;
                    if (boneFixups.TryGetValue(smdBonePose.Bone.Name, out boneFixup))
                    {
                        // The fixup translations inputted by the user should be in SMD coordinate space
                        pacBonePose.MF += boneFixup.Item1.Y * -1; // So we can convert them into engine coordinate space
                        pacBonePose.MR += boneFixup.Item1.X * -1;
                        pacBonePose.MU += boneFixup.Item1.Z * 1;

                        pacBonePose.RF += boneFixup.Item2.X * 1;
                        pacBonePose.RR += boneFixup.Item2.Y * 1;
                        pacBonePose.RU += boneFixup.Item2.Z * 1;
                    }

                    // Keep rotations in -180 to 180 range or else Source gets real unhappy
                    pacBonePose.RF = MathHelper.PeriodicClamp(pacBonePose.RF, -180, 180);
                    pacBonePose.RR = MathHelper.PeriodicClamp(pacBonePose.RR, -180, 180);
                    pacBonePose.RU = MathHelper.PeriodicClamp(pacBonePose.RU, -180, 180);

                    pacFrame.BoneInfo[smdBonePose.Bone.Name] = pacBonePose;
                }

                pacAnim.FrameData.Add(pacFrame);
                lastSmdFrameTime = smdFrame.FrameTime;
            }


            ///// Optimization
            // We can completely omit bones that have an extremely negligible transform (and thus no perceptible visual movement)
            if (optimizeLevel >= 1)
            {
                // Get all bones that made it into the pac data
                HashSet <string> allPacBones = new HashSet <string>();
                foreach (PacFrame frame in pacAnim.FrameData)
                {
                    foreach (string boneName in frame.BoneInfo.Keys)
                    {
                        allPacBones.Add(boneName);
                    }
                }

                // Find bones which have or are very close to a 0,0,0 0,0,0 transform for the entire animation, and thus will have no visual effect (pac3 animations are additive)
                List <string> identityBones = new List <string>();
                foreach (string pacBoneName in allPacBones)
                {
                    bool nearIdentity = true;
                    foreach (PacFrame frame in pacAnim.FrameData)
                    {
                        PacBonePose pose = null;
                        if (frame.BoneInfo.TryGetValue(pacBoneName, out pose))
                        {
                            if (new Vector3(pose.MF, pose.MR, pose.MU).Length() > 0.0001 || pose.RF > 0.0005 || pose.RR > 0.0005 || pose.RU > 0.0005)
                            {
                                nearIdentity = false;
                                break;
                            }
                        }
                    }

                    if (nearIdentity)
                    {
                        identityBones.Add(pacBoneName);
                    }
                }

                // Remove those bones from all frames of the animation
                foreach (string pacBoneName in identityBones)
                {
                    foreach (PacFrame frame in pacAnim.FrameData)
                    {
                        frame.BoneInfo.Remove(pacBoneName);
                    }

                    Print("- Bone \"" + pacBoneName + "\" has virtually zero movement and has been optimized out", 1);
                }
            }


            // Return subtracted SMD data for dumping
            if (subtractionBaseSmd != null)
            {
                subtractedSmdData = smdData;
            }
            else
            {
                subtractedSmdData = null;
            }

            return(pacAnim);
        }
Beispiel #3
0
        public SmdData(string sourceFilename, string[] rawLines)
        {
            SourceFilename = sourceFilename;

            // Associate line numbers with source data
            List <NumberedLine> lines = new List <NumberedLine>();

            for (int i = 0; i < rawLines.Length; i++)
            {
                lines.Add(new NumberedLine(rawLines[i], i + 1));
            }

            // Strip comments and empty lines
            lines.RemoveAll(l => l.Text.TrimStart().StartsWith("//") || string.IsNullOrWhiteSpace(l.Text));

            if (lines.Count == 0)
            {
                throw new Exception("SMD file has no data.");
            }

            // Header check
            // This is extremely minimal for Valve's SMD format. It's just the "version" tag.
            string[] rawVersionLine = lines[0].Text.Trim().Split(' ');
            if (string.IsNullOrWhiteSpace(rawVersionLine[0]) || rawVersionLine[0] != "version")
            {
                throw new Exception(ErrInvalid(lines[0].LineNumber, "\"version\" tag is missing."));
            }
            if (rawVersionLine.Length != 2)
            {
                throw new Exception(ErrInvalid(lines[0].LineNumber, "\"version\" tag is invalid."));
            }
            if (rawVersionLine[1] != "1")
            {
                throw new Exception(ErrUnknown(lines[0].LineNumber, "\"version\" has a value of " + rawVersionLine[1] + ", expected 1."));
            }

            // Top-level data block structure
            List <NumberedLine> dbNodes    = null;
            List <NumberedLine> dbSkeleton = null;
            // We don't need the "triangles" or "vertexanimation" data blocks

            // Extract relevant data blocks
            bool   inBlock      = false;
            string inBlockName  = null;
            int    inBlockStart = -1;

            for (int i = 1; i < lines.Count; i++)
            {
                NumberedLine line     = lines[i];
                string       linetext = line.Text.Trim();
                if (string.IsNullOrWhiteSpace(linetext))
                {
                    continue;
                }

                if (linetext == "nodes" || linetext == "skeleton" || linetext == "vertexanimation")
                {
                    if (inBlock)
                    {
                        throw new Exception(ErrInvalid(line.LineNumber, "New data block \"" + linetext + "\" starts in the middle of the previous \"" + inBlockName + "\" data block."));
                    }

                    inBlock      = true;
                    inBlockName  = linetext;
                    inBlockStart = i;
                }

                if (linetext == "end")
                {
                    if (inBlock)
                    {
                        inBlock = false;
                        if (inBlockName == "nodes")
                        {
                            dbNodes = lines.GetRange(inBlockStart, i - inBlockStart + 1);
                        }
                        else if (inBlockName == "skeleton")
                        {
                            dbSkeleton = lines.GetRange(inBlockStart, i - inBlockStart + 1);
                        }
                    }
                    else
                    {
                        throw new Exception(ErrInvalid(line.LineNumber, "Unexpected \"end\" keyword outside of any data blocks."));
                    }
                }
            }

            // Ensure data blocks exist
            if (dbNodes == null)
            {
                throw new Exception(ErrInvalid("\"nodes\" data block is missing."));
            }
            if (dbSkeleton == null)
            {
                throw new Exception(ErrInvalid("\"skeleton\" data block is missing."));
            }
            // And that they arent empty
            if (dbNodes.Count == 2)
            {
                throw new Exception(ErrInvalid(dbNodes[0].LineNumber, "\"nodes\" data block is empty."));
            }
            if (dbSkeleton.Count == 2)
            {
                throw new Exception(ErrInvalid(dbSkeleton[0].LineNumber, "\"skeleton\" data block is empty."));
            }


            ///// Process node block to build skeleton hierarchy
            // Bone definition order does NOT have to be sequential, so we'll set up the parenting AFTER discovering all bones
            // Crowbar always writes bones sequentially, but other software might be lazy and write them out of order
            List <SmdBone> bones = new List <SmdBone>();

            for (int i = 1; i < dbNodes.Count - 1; i++)
            {
                NumberedLine line     = dbNodes[i];
                string       linetext = line.Text.Trim();

                string[] linetextParts = linetext.Split(' '); // This is ok because source engine bones cannot have spaces in their names
                if (linetextParts.Length != 3)
                {
                    throw new Exception(ErrInvalid(line.LineNumber, "Bone definition is an invalid format."));
                }

                int boneID = -1;
                if (!int.TryParse(linetextParts[0], out boneID))
                {
                    throw new Exception(ErrInvalid(line.LineNumber, "Bone definition is invalid; bone ID is not a valid number."));
                }

                string boneName = linetextParts[1].Trim('"'); // Trim the " quotes that always present but useless
                if (string.IsNullOrWhiteSpace(boneName))
                {
                    throw new Exception(ErrInvalid(line.LineNumber, "Bone definition is invalid; bone name is empty."));
                }
                if (bones.Where(b => b.Name == boneName).ToList().Count > 0)
                {
                    throw new Exception(ErrInvalid(line.LineNumber, "Bone definition is invalid; duplicate bone name."));
                }

                int boneParentID = -2;
                if (!int.TryParse(linetextParts[2], out boneParentID))
                {
                    throw new Exception(ErrInvalid(line.LineNumber, "Bone definition is invalid; bone parent ID is not a valid number."));
                }

                SmdBone bone = new SmdBone(boneID, boneName, boneParentID);
                bone.SmdSourceLine = line;
                bones.Add(bone);
            }

            // Find and verify the root bone
            List <SmdBone> rootBones = bones.Where(b => b.ParentID == -1).ToList();

            if (rootBones.Count > 1)
            {
                throw new Exception(ErrInvalid(rootBones[1].SmdSourceLine.LineNumber, "Invalid skeleton hierarchy. Only be one root bone can exist."));
            }
            SmdBone rootBone = rootBones[0];

            // Create hierarchy
            List <SmdBone> orphanBonesLeft = new List <SmdBone>(bones);

            orphanBonesLeft.Remove(rootBone);
            void buildHierarchy(SmdBone self, List <SmdBone> orphanBones)
            {
                foreach (SmdBone orphanBone in orphanBones.ToList()) // Iterate a copy
                {
                    if (orphanBone == self)
                    {
                        continue;
                    }

                    if (orphanBone.ParentID == self.ID)
                    {
                        self.Children.Add(orphanBone);
                        orphanBone.Parent = self;
                        orphanBones.Remove(orphanBone);

                        buildHierarchy(orphanBone, orphanBones);
                    }
                }
            }

            buildHierarchy(rootBone, orphanBonesLeft);
            if (orphanBonesLeft.Count > 0)
            {
                string message = "\n";
                foreach (SmdBone bone in orphanBonesLeft)
                {
                    message += "Orphaned bone \"" + bone.Name + "\" (ID: " + bone.ID + "). No bone with parent ID " + bone.ParentID + " exists.\n";
                }
                throw new Exception(ErrInvalid(message.TrimEnd('\n')));
            }

            // Create skeleton object
            Skeleton = new SmdSkeleton(bones, rootBone);


            ///// Process skeleton block to find animations frames
            List <List <NumberedLine> > timeBlocks     = new List <List <NumberedLine> >();
            List <NumberedLine>         buildTimeBlock = null;
            bool startedFirstBlockBuild = false;
            int  lastTimeNumber         = int.MinValue;

            for (int i = 1; i < dbSkeleton.Count - 1; i++)
            {
                NumberedLine line          = dbSkeleton[i];
                string       linetext      = line.Text.Trim();
                string[]     linetextParts = linetext.Split(' ');
                if (linetext.Length >= 4 && linetext.Substring(0, 4) == "time")
                {
                    if (linetextParts.Length != 2)
                    {
                        throw new Exception(ErrInvalid(line.LineNumber, "Invalid \"time\" block header."));
                    }

                    int timeNumber = -1;
                    if (!int.TryParse(linetextParts[1], out timeNumber))
                    {
                        throw new Exception(ErrInvalid(line.LineNumber, "\"time\" block number is not a valid number."));
                    }

                    if (timeNumber <= lastTimeNumber)
                    {
                        throw new Exception(ErrInvalid(line.LineNumber, "\"time\" block number is not sequential to the previous time block."));
                    }
                    lastTimeNumber = timeNumber;

                    if (!startedFirstBlockBuild)
                    {
                        buildTimeBlock         = new List <NumberedLine>();
                        startedFirstBlockBuild = true;
                    }
                    else
                    {
                        //if (buildTimeBlock.Count < 2) // aka just the "time" header and no actual bone pose data
                        //    throw new Exception(ErrInvalid(line.LineNumber, "Empty \"time\" block."));
                        // This might actually be valid SMD. The Valve wiki isn't clear about this case.

                        timeBlocks.Add(buildTimeBlock);
                        buildTimeBlock = new List <NumberedLine>();
                    }
                }
                buildTimeBlock.Add(line);
            }
            timeBlocks.Add(buildTimeBlock);

            // Create timeline for animation
            Timeline = new SmdTimeline(Skeleton);
            // Add all explicit frames
            foreach (List <NumberedLine> timeBlock in timeBlocks)
            {
                SmdTimelineFrame frame = new SmdTimelineFrame();

                NumberedLine header          = timeBlock[0];
                string[]     headertextParts = header.Text.Trim().Split(' ');
                frame.FrameTime = (float)int.Parse(headertextParts[1]);

                for (int i = 1; i < timeBlock.Count; i++)
                {
                    NumberedLine boneline          = timeBlock[i];
                    string       bonelinetext      = boneline.Text.Trim();
                    string[]     bonelinetextParts = bonelinetext.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

                    if (bonelinetextParts.Length != 7)
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Bone pose data is an invalid format."));
                    }

                    int boneID = -1;
                    if (!int.TryParse(bonelinetextParts[0], out boneID))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; bone ID is not a valid number."));
                    }

                    float bonePosX = 0f;
                    if (!float.TryParse(bonelinetextParts[1], out bonePosX))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; bone X translation is not a valid number."));
                    }
                    float bonePosY = 0f;
                    if (!float.TryParse(bonelinetextParts[2], out bonePosY))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; bone Y translation is not a valid number."));
                    }
                    float bonePosZ = 0f;
                    if (!float.TryParse(bonelinetextParts[3], out bonePosZ))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; bone Z translation is not a valid number."));
                    }

                    float boneRotX = 0f;
                    if (!float.TryParse(bonelinetextParts[4], out boneRotX))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; bone X rotation is not a valid number."));
                    }
                    float boneRotY = 0f;
                    if (!float.TryParse(bonelinetextParts[5], out boneRotY))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; bone Y rotation is not a valid number."));
                    }
                    float boneRotZ = 0f;
                    if (!float.TryParse(bonelinetextParts[6], out boneRotZ))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; bone Z rotation is not a valid number."));
                    }

                    SmdBonePose bonePose = new SmdBonePose();

                    SmdBone targetBone = null;
                    if (!Skeleton.BoneByID.TryGetValue(boneID, out targetBone))
                    {
                        throw new Exception(ErrInvalid(boneline.LineNumber, "Invalid bone pose; no bone exists with the ID " + boneID + "."));
                    }

                    foreach (SmdBonePose existingBonePose in frame.ExplicitBonePoses)
                    {
                        if (existingBonePose.Bone == targetBone)
                        {
                            throw new Exception(ErrInvalid(boneline.LineNumber, "Duplicate bone pose."));
                        }
                    }

                    bonePose.Bone     = targetBone;
                    bonePose.Position = new Vector3(bonePosX, bonePosY, bonePosZ);
                    bonePose.Rotation = new Vector3(boneRotX, boneRotY, boneRotZ);

                    frame.AddBonePose(bonePose);
                }

                Timeline.AddFrame(frame); // Frames will be stored sequentially as they were defined in the SDM and any pose interpolation will occur on demand if needed
            }

            Print("- " + bones.Count + " bones, " + Timeline.ExplicitFrames.Count + " frames of animation", 1);
        }