Thanks, guys. I figured it out given the ideas upthread as well as using some code from MacKracken’s Arc Length.
I wrote a Python script (pretty rough, but it works) where I set the name of the curve object, the distance for a full stride, how many frames it takes to go a full stride, the start frame, and the desired end frame. Then it’ll print out the values needed for the Follow Path constraint’s Offset parameter. If I put in too many frames and the calculations happened to overshoot the distance on the path (so much that the Offset value is greater than 1), then the script will show a different end frame where we are as close to under 1 as possible.
I’ve tested this and my character walks perfectly along the path – no sliding at all. (EDIT: Well, maybe not “no sliding”…it looks like I’ll need to compensate a bit on a steep curve up or down…and presumably on a hard turn. But for relatively straight paths, it appears to work well.) It’s exactly what I was looking to do. As I thought about it, I could make the character follow different paths simply by reducing the influence of the Follow Path constraint as needed. (Of course, I would have to re-position the character so it would look seamless, but it would be possible. I may not end up doing this, though…it’s probably more hassle than it’s worth.)
I plan to improve this script, where it’ll automatically insert the keyframes in the Follow Path constraint (as well as provide an option to not start at the very beginning of the path), but this is sufficient for now.
Here’s the code in case if anyone finds it useful. Note that this expects to be run from Blender 2.8’s Scripting workspace, where a Python Console window is present.
from console_python import get_console
from contextlib import redirect_stdout
from mathutils import *
from bpy.props import *
import bpy
import math
###############################################################################
# Calculate Path Offset Value
#
# Figure out the Follow Path constraint's Offset value that we need in order to
# use a walk cycle along a path.
PATH_NAME = 'NurbsPath' # The name of the path we want to walk
FULL_STRIDE = 0.25 # How far is our full stride in Blender units?
FRAMES_TO_GO_FULL_STRIDE = 13 # How many frames do we need to go per one full stride?
FRAME_START = 101 # When do we start walking on the path?
FRAME_END = 520 # When do we stop walking on the path?
###############################################################################
# Version History
#
# 1.0.0 - 2020-08-03: Original version
#
###############################################################################
SCRIPT_NAME = "calculate_path_offset_value"
###############################################################################
def add_scrollback(override, text, text_type): # Necessary for printing to the console
append = bpy.ops.console.scrollback_append
for l in text.split("\n"):
txt = l.replace("\t", " ")
append(override, text=txt, type=text_type)
def print_msg(text): # Necessary for printing to the console
con = stdout = stderr = None
for area in bpy.context.screen.areas:
if area.type == 'CONSOLE':
hsh = hash(area.regions[-1])
con, stdout, stderr = get_console(hsh)
break
stdout.seek(0)
stdout.truncate(0)
with redirect_stdout(stdout):
con.push('print("' + text + '")')
out = stdout.getvalue()
if out:
override = {'area': area, 'region': area.regions[-1], 'space_data': area.spaces.active}
add_scrollback(override, out, 'OUTPUT')
#calcs a nurbs knot vector
def knots(n, order, type=0):#0 uniform 1 endpoints 2 bezier
kv = []
t = n+order
if type==0:
for i in range(0, t):
kv.append(1.0*i)
elif type==1:
k=0.0
for i in range(1, t+1):
kv.append(k)
if i>=order and i<=n:
k+=1.0
elif type==2:
if order==4:
k=0.34
for a in range(0,t):
if a>=order and a<=n: k+=0.5
kv.append(floor(k))
k+=1.0/3.0
elif order==3:
k=0.6
for a in range(0, t):
if a >=order and a<=n: k+=0.5
kv.append(floor(k))
##normalize the knot vector
for i in range(0, len(kv)):
kv[i]=kv[i]/kv[-1]
return kv
#nurbs curve evaluation
def C(t, order, points, weights, knots):
#c = Point([0,0,0])
c = Vector()
rational = 0
i = 0
while i < len(points):
b = B(i, order, t, knots)
p = points[i] * (b * weights[i])
c = c + p
rational = rational + b*weights[i]
i = i + 1
return c * (1.0/rational)
#nurbs basis function
def B(i,k,t,knots):
ret = 0
if k>0:
n1 = (t-knots[i])*B(i,k-1,t,knots)
d1 = knots[i+k] - knots[i]
n2 = (knots[i+k+1] - t) * B(i+1,k-1,t,knots)
d2 = knots[i+k+1] - knots[i+1]
if d1 > 0.0001 or d1 < -0.0001:
a = n1 / d1
else:
a = 0
if d2 > 0.0001 or d2 < -0.0001:
b = n2 / d2
else:
b = 0
ret = a + b
#print "B i = %d, k = %d, ret = %g, a = %g, b = %g\n"%(i,k,ret,a,b)
else:
if knots[i] <= t and t <= knots[i+1]:
ret = 1
else:
ret = 0
return ret
#gets nurbs polygon control points on global coordinates
def getnurbspoints(spl, mw):
pts = []
ws = []
for p in spl.points:
v = Vector(p.co[0:3]) @ mw
pts.append(v)
ws.append(p.weight)
return pts , ws
#calculates a global parameter t along all control points
#t=0 begining of the curve
#t=1 ending of the curve
def calct(obj, t):
spl=None
mw = obj.matrix_world
if obj.data.splines.active==None:
if len(obj.data.splines)>0:
spl=obj.data.splines[0]
else:
spl = obj.data.splines.active
if spl==None:
return False
if spl.type=="BEZIER":
points = spl.bezier_points
nsegs = len(points)-1
d = 1.0/nsegs
seg = int(t/d)
t1 = t/d - int(t/d)
if t==1:
seg-=1
t1 = 1.0
p = getbezpoints(spl,mw, seg)
coord = cubic(p, t1)
return coord
elif spl.type=="NURBS":
data = getnurbspoints(spl, mw)
pts = data[0]
ws = data[1]
order = spl.order_u
n = len(pts)
ctype = spl.use_endpoint_u
kv = knots(n, order, ctype)
coord = C(t, order-1, pts, ws, kv)
return coord
def get_path_length(obj):
length = 0.0
if obj.type=="CURVE":
prec = 1000 #precision
inc = 1/prec #increments
### TODO: set a custom precision value depending the number of curve points
#that way it can gain on accuracy in less operations.
#subdivide the curve in 1000 lines and sum its magnitudes
for i in range(0, prec):
ti = i*inc
tf = (i+1)*inc
a = calct(obj, ti)
b = calct(obj, tf)
r = (b-a).magnitude
length+=r
return length
###############################################################################
# Main program
###############################################################################
print_msg('**********************************')
print_msg(SCRIPT_NAME + ' - START')
path_length = get_path_length(bpy.data.objects[PATH_NAME])
stride_length = FULL_STRIDE / path_length
total_frames = (FRAME_END - FRAME_START)
end_value = (total_frames / FRAMES_TO_GO_FULL_STRIDE) * stride_length
print_msg(' ----- RESULTS -----')
print_msg(' ' + str(PATH_NAME) + ' has a length of ' + str(path_length))
print_msg(' One full stride length for a Follow Path constraint Offset: ' + str(stride_length))
if (end_value > 1.0):
print_msg(' *** WARNING! INPUT PARAMETERS OVERSHOOT THE OFFSET VALUE! ***')
print_msg(' *** We are going to modify which frame where the animation ends so that we can end with a useable Offset value. ***')
while (end_value > 1.0):
end_value -= stride_length / FRAMES_TO_GO_FULL_STRIDE
FRAME_END -= 1
print_msg(' FRAME ' + str(FRAME_START) + ' Offset: 0')
print_msg(' FRAME ' + str(FRAME_END) + ' Offset: ' + str(end_value))
print_msg(' -------------------')
print_msg(SCRIPT_NAME + ' - END')
print_msg('**********************************')
print_msg('Done running script ' + SCRIPT_NAME)