I originally modelled this clockwork spider over 3 days for a short challenge. I gathered references of various clock and watch parts, and pieced them together to create the model. The final product was 3 renders (using V-Ray + compositing in After Effects)
Much later, I rigged the model for the purposes of procedurally animating it in Unity for a showcase in Tokyo.
The spider uses no animated frames for its movement. Instead, all movement is driven by code — Procedural Animation — and the full logic all lives in 1 script, which I’ll provide here in its entirety:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class SpiderController : MonoBehaviour
{
public float SpiderDesiredDistanceMin;
public float SpiderDesiredDistanceMax;
[Header("Body")]
[SerializeField] bool _followMouse = true;
[SerializeField] Transform _bodyControl;
[SerializeField] float _rotateToTargetSpeed = 1f;
[SerializeField] Vector3 vec;
[Header("Legs")]
[SerializeField] float _legStepOvershootFraction = 1f;
[SerializeField] float _legStepHeight = 1f;
[SerializeField] List<LegController> _legControllers;
[Header("Hands")]
[SerializeField] Transform _hand1;
[SerializeField] Transform _hand2;
[SerializeField] Transform _hand3;
[SerializeField] float _hand2mult;
[SerializeField] float _hand2offset;
[SerializeField] float _hand3mult;
[SerializeField] float _hand3offset;
public Vector3 MousePosition => GameManager.Instance.MousePosition;
NavMeshAgent _nav;
LayerMask _groundLayerMask;
[Serializable]
class LegController
{
public string Name;
public Transform LegControl;
public Transform RestTransform;
public float MaxDeviationFromRest;
public float StepDuration;
public LegController OppositeLeg;
[HideInInspector][NonSerialized] public bool IsMoving;
[HideInInspector][NonSerialized] public bool JustFinishedMoving;
[HideInInspector] public Vector3 RestPosition;
public Quaternion RestRotation => RestTransform.rotation;
public Vector3 Position
{
get => LegControl.position;
set => LegControl.position = value;
}
public Quaternion Rotation
{
get => LegControl.rotation;
set => LegControl.rotation = value;
}
}
void Start()
{
_nav = GetComponent<NavMeshAgent>();
_nav.updateRotation = false;
_groundLayerMask = LayerMask.GetMask("Ground");
_legControllers[0].OppositeLeg = _legControllers[4];
_legControllers[4].OppositeLeg = _legControllers[0];
_legControllers[0].JustFinishedMoving = true;
_legControllers[1].OppositeLeg = _legControllers[6];
_legControllers[6].OppositeLeg = _legControllers[1];
_legControllers[1].JustFinishedMoving = true;
_legControllers[2].OppositeLeg = _legControllers[5];
_legControllers[5].OppositeLeg = _legControllers[2];
_legControllers[2].JustFinishedMoving = true;
_legControllers[3].OppositeLeg = _legControllers[7];
_legControllers[7].OppositeLeg = _legControllers[3];
_legControllers[3].JustFinishedMoving = true;
}
void Update()
{
Move();
Rotate();
UpdateHands();
UpdateAllLegs();
}
void Move()
{
if (!_followMouse) return;
if (Time.frameCount % 15 != 0) return; // run every 15 frames
float distanceFromMouse = Vector3.Distance(transform.position, MousePosition);
Vector3 directionToSpider = (transform.position - MousePosition).normalized;
if (distanceFromMouse > SpiderDesiredDistanceMax)
{
Vector3 targetPosition = MousePosition + directionToSpider * SpiderDesiredDistanceMin;
_nav.SetDestination(targetPosition);
}
else if (distanceFromMouse < SpiderDesiredDistanceMin)
{
Vector3 targetPosition = MousePosition + directionToSpider * SpiderDesiredDistanceMax;
_nav.SetDestination(targetPosition);
}
}
void Rotate()
{
if (!_followMouse) return;
Quaternion targetRotation = Quaternion.LookRotation((MousePosition - transform.position).normalized);
Quaternion targetRotationYonly = Quaternion.Euler(0, targetRotation.eulerAngles.y, 0);
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotationYonly, _rotateToTargetSpeed * Time.deltaTime);
if (Physics.Raycast(_bodyControl.transform.position + _bodyControl.transform.up * 5f, -_bodyControl.transform.up * 10f, out RaycastHit hit, Mathf.Infinity, _groundLayerMask))
{
Vector3 projectedForward = transform.forward - hit.normal * Vector3.Dot(transform.forward, hit.normal);
_bodyControl.rotation = Quaternion.LookRotation(projectedForward.normalized, hit.normal);
}
}
void UpdateAllLegs()
{
foreach (LegController leg in _legControllers)
{
UpdateLeg(leg);
}
}
void UpdateLeg(LegController leg)
{
// Plant rest positions on the floor
if (Physics.Raycast(leg.RestTransform.position + Vector3.up * 10f, -Vector3.up, out RaycastHit rayHit, Mathf.Infinity, _groundLayerMask))
{
leg.RestPosition = rayHit.point;
}
if (leg.IsMoving) return;
if (leg.JustFinishedMoving)
{
leg.JustFinishedMoving = false;
return;
}
if (leg.OppositeLeg.IsMoving) return;
float distanceFromRestPos = Vector3.Distance(leg.Position, leg.RestPosition);
if (distanceFromRestPos > leg.MaxDeviationFromRest)
{
StartCoroutine(MoveLeg(leg));
}
}
IEnumerator MoveLeg(LegController leg)
{
leg.IsMoving = true;
Quaternion startRotation = leg.Rotation;
Vector3 startPosition = leg.Position;
Quaternion endRotation = leg.RestRotation;
Vector3 endPosition = leg.RestPosition;
float actorSpeedFactor = Mathf.Clamp(_nav.velocity.magnitude, Mathf.Epsilon, _nav.velocity.magnitude) / _nav.speed;
Vector3 direction = endPosition - startPosition;
float overshootDistance = Mathf.Clamp(leg.MaxDeviationFromRest * _legStepOvershootFraction * actorSpeedFactor, leg.MaxDeviationFromRest * _legStepOvershootFraction / 10f, leg.MaxDeviationFromRest * _legStepOvershootFraction);
Vector3 overshootVector = direction * overshootDistance;
endPosition += overshootVector;
// try to ground the feet
if (Physics.Raycast(endPosition + transform.up * 10f, -transform.up, out RaycastHit rayHit, Mathf.Infinity, _groundLayerMask))
{
endPosition = rayHit.point;
}
Vector3 center = (startPosition + endPosition) / 2f;
center += Vector3.up * Vector3.Distance(startPosition, endPosition) * _legStepHeight;
float stepDurationNormalized = Mathf.Clamp(leg.StepDuration / actorSpeedFactor, leg.StepDuration, leg.StepDuration * 2f);
float timer = 0f;
do
{
timer += Time.deltaTime;
float normalizedTime = timer / stepDurationNormalized;
normalizedTime = Easing.Cubic.InOut(normalizedTime);
// quadratic bezier
leg.Position = Vector3.Lerp(
Vector3.Lerp(startPosition, center, normalizedTime),
Vector3.Lerp(center, endPosition, normalizedTime),
normalizedTime
);
leg.Rotation = Quaternion.Slerp(startRotation, endRotation, normalizedTime);
yield return null;
}
while (timer < stepDurationNormalized);
leg.IsMoving = false;
leg.JustFinishedMoving = true;
}
void UpdateHands()
{
_hand1.localRotation.ToAngleAxis(out float angle2, out Vector3 axis2);
angle2 *= _hand2mult;
_hand2.localRotation = Quaternion.AngleAxis(angle2, axis2) * Quaternion.AngleAxis(_hand2offset, transform.up);
_hand1.localRotation.ToAngleAxis(out float angle3, out Vector3 axis3);
angle3 *= _hand3mult;
_hand3.localRotation = Quaternion.AngleAxis(angle3, axis3) * Quaternion.AngleAxis(_hand3offset, transform.up);
}
}
The concept is fairly simple when broken down:
With the described logic above, it will seem like the legs are always ‘catching up’ to the body, because that’s essentially what is happening — the body moves first, and the legs try to follow. To mitigate this, the logic overshoots the legs’ stepping positions.
With everything put together and some additional damping + easing, the result is fairly convincing despite the logic being quite simple.
For the demo, the user moves a laser pointer around the screen, and 3 clockwork spiders will try to follow the laser pointer.
This has also been ported to run on mobile, so if you ever see me in person, make sure to ask me to see it :)