Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created November 20, 2025 05:12
Show Gist options
  • Select an option

  • Save EncodeTheCode/dad9abfc54b51b713962dc496a7be24c to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/dad9abfc54b51b713962dc496a7be24c to your computer and use it in GitHub Desktop.
# glb_player_glfw.py
import time, ctypes, math
import numpy as np
import glfw
from OpenGL.GL import *
from OpenGL.GL.shaders import compileProgram, compileShader
import pyassimp
# ---------------- math helpers ----------------
def identity4(): return np.eye(4, dtype=np.float32)
def mat4_from_list(l): return np.array(l, dtype=np.float32).reshape((4,4)).T
def translate_matrix(v):
m = identity4(); m[:3,3] = np.array(v,dtype=np.float32); return m
def scale_matrix(s):
m = identity4(); m[0,0],m[1,1],m[2,2] = float(s[0]),float(s[1]),float(s[2]); return m
def quat_to_mat4(q):
x,y,z,w = float(q[0]),float(q[1]),float(q[2]),float(q[3])
n = x*x+y*y+z*z+w*w
if n < 1e-8: return identity4()
s = 2.0 / n
xx,yy,zz = x*x*s, y*y*s, z*z*s
xy, xz, yz = x*y*s, x*z*s, y*z*s
wx, wy, wz = w*x*s, w*y*s, w*z*s
m = np.array([
[1.0-(yy+zz), xy - wz, xz + wy, 0.0],
[xy + wz, 1.0-(xx+zz), yz - wx, 0.0],
[xz - wy, yz + wx, 1.0-(xx+yy),0.0],
[0.0, 0.0, 0.0, 1.0]
], dtype=np.float32)
return m
def slerp(q0, q1, t):
q0 = np.array(q0, dtype=np.float64)
q1 = np.array(q1, dtype=np.float64)
dot = np.dot(q0, q1)
if dot < 0.0:
q1 = -q1; dot = -dot
if dot > 0.9995:
res = q0 + t*(q1 - q0)
res /= np.linalg.norm(res); return res.astype(np.float32)
theta_0 = math.acos(max(-1.0, min(1.0, dot)))
theta = theta_0 * t
q2 = q1 - q0 * dot
q2 /= np.linalg.norm(q2)
res = q0*math.cos(theta) + q2*math.sin(theta)
return res.astype(np.float32)
def lerp(a,b,t): return (1.0-t)*np.array(a,dtype=np.float32) + t*np.array(b,dtype=np.float32)
# ---------------- GLSL (GPU skinning) ----------------
VERT = """
#version 330 core
layout(location=0) in vec3 in_pos;
layout(location=1) in vec3 in_nrm;
layout(location=2) in ivec4 in_bids;
layout(location=3) in vec4 in_wts;
uniform mat4 u_proj;
uniform mat4 u_view;
uniform mat4 u_model;
uniform mat4 u_bones[120]; // increase if needed
out vec3 v_n;
void main(){
mat4 skin = mat4(0.0);
for(int i=0;i<4;i++){
int id = in_bids[i];
float w = in_wts[i];
if(id>=0 && w>0.0) skin += u_bones[id] * w;
}
vec4 p = skin * vec4(in_pos,1.0);
vec3 n = mat3(skin) * in_nrm;
gl_Position = u_proj * u_view * u_model * p;
v_n = normalize(n);
}
"""
FRAG = """
#version 330 core
in vec3 v_n;
out vec4 o;
void main(){
float l = max(dot(normalize(v_n), normalize(vec3(0.4,0.6,1.0))), 0.0);
vec3 col = vec3(0.2) + vec3(0.8)*l;
o = vec4(col,1.0);
}
"""
# ---------------- GLB Player class ----------------
class GLBPlayer:
def __init__(self, glb_path, max_bones=120):
self.scene = pyassimp.load(glb_path)
self.max_bones = max_bones
self._collect_nodes()
self._collect_bones()
self._build_mesh_vbos()
self.program = compileProgram(compileShader(VERT, GL_VERTEX_SHADER),
compileShader(FRAG, GL_FRAGMENT_SHADER))
self.current = None
self.prev = None
self.anim_time = 0.0
self.blend_time = 0.0
self.blend_len = 0.15
self.bone_matrices = np.array([identity4()]*self.max_bones, dtype=np.float32)
def _collect_nodes(self):
self.nodes = {}
def walk(n):
self.nodes[n.name] = n
for c in n.children: walk(c)
walk(self.scene.rootnode)
def _collect_bones(self):
self.bone_names = []
self.bone_index = {}
inv = []
for mesh in self.scene.meshes:
if not getattr(mesh, 'bones', None): continue
for b in mesh.bones:
name = b.name
if name not in self.bone_index:
idx = len(self.bone_names)
self.bone_index[name] = idx
self.bone_names.append(name)
inv.append(mat4_from_list(b.offsetmatrix))
# pad
while len(inv) < self.max_bones:
inv.append(identity4())
self.inv_bind = np.stack(inv).astype(np.float32)
# animations
self.anims = []
for a in getattr(self.scene, 'animations', []):
channels = {}
for ch in a.channels:
channels[ch.nodename] = ch
self.anims.append({'name': a.name or str(len(self.anims)),
'duration': a.duration if a.duration>0 else 1.0,
'tps': a.tickspersecond if a.tickspersecond>0 else 25.0,
'channels': channels})
if not self.anims:
self.anims.append({'name':'idle','duration':1.0,'tps':25.0,'channels':{}})
def _build_mesh_vbos(self):
self.meshes = []
for mesh in self.scene.meshes:
v = np.array(mesh.vertices, dtype=np.float32)
n = np.array(mesh.normals if mesh.normals is not None else [[0,0,1]]*len(v), dtype=np.float32)
count = len(v)
bids = np.full((count,4), -1, dtype=np.int32)
wts = np.zeros((count,4), dtype=np.float32)
if mesh.bones:
for b in mesh.bones:
bid = self.bone_index.get(b.name, -1)
for w in b.weights:
vi, wt = w.vertexid, w.weight
for slot in range(4):
if wts[vi,slot] == 0.0:
bids[vi,slot] = bid
wts[vi,slot] = wt
break
idx = np.array([i for f in mesh.faces for i in f], dtype=np.uint32)
vao = glGenVertexArrays(1); glBindVertexArray(vao)
vbo = glGenBuffers(1); glBindBuffer(GL_ARRAY_BUFFER, vbo)
inter = np.hstack([v, n]).astype(np.float32)
glBufferData(GL_ARRAY_BUFFER, inter.nbytes, inter, GL_STATIC_DRAW)
stride = 6*4
glEnableVertexAttribArray(0); glVertexAttribPointer(0,3,GL_FLOAT,False,stride,ctypes.c_void_p(0))
glEnableVertexAttribArray(1); glVertexAttribPointer(1,3,GL_FLOAT,False,stride,ctypes.c_void_p(12))
bid_buf = glGenBuffers(1); glBindBuffer(GL_ARRAY_BUFFER, bid_buf)
glBufferData(GL_ARRAY_BUFFER, bids.nbytes, bids, GL_STATIC_DRAW)
glEnableVertexAttribArray(2); glVertexAttribIPointer(2,4,GL_INT,4*4,ctypes.c_void_p(0))
w_buf = glGenBuffers(1); glBindBuffer(GL_ARRAY_BUFFER, w_buf)
glBufferData(GL_ARRAY_BUFFER, wts.nbytes, wts, GL_STATIC_DRAW)
glEnableVertexAttribArray(3); glVertexAttribPointer(3,4,GL_FLOAT,False,4*4,ctypes.c_void_p(0))
ebo = glGenBuffers(1); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, idx.nbytes, idx, GL_STATIC_DRAW)
glBindVertexArray(0)
self.meshes.append({'vao':vao, 'count':len(idx)})
# default model matrix
self.model = identity4()
# play by name or index; crossfade from current if present
def play(self, anim, blend=0.15, loop=True):
if isinstance(anim, int): anim = self.anims[anim]['name']
target = None
for a in self.anims:
if a['name'] == anim:
target = a; break
if target is None: raise ValueError("Animation not found: "+str(anim))
if self.current is None:
self.current = target; self.anim_time = 0.0; self.loop = loop; self.prev = None; self.blend_time = 0.0
else:
self.prev = self.current
self.current = target
self.blend_len = blend
self.blend_time = 0.0
# evaluate a node's local TRS from an animation at tick t
def _eval_channel(self, chan, t):
# return (pos, rot, scl) local
pos = None; rot = None; scl = None
if getattr(chan, 'positionkeys', None):
times = [k[0] for k in chan.positionkeys]
vals = [np.array(k[1],dtype=np.float32) for k in chan.positionkeys]
if t <= times[0]: pos = vals[0]
elif t >= times[-1]: pos = vals[-1]
else:
for i in range(len(times)-1):
if times[i] <= t <= times[i+1]:
tt = (t-times[i])/(times[i+1]-times[i])
pos = lerp(vals[i], vals[i+1], tt); break
if getattr(chan, 'rotationkeys', None):
times = [k[0] for k in chan.rotationkeys]
vals = [np.array([k[1][0],k[1][1],k[1][2],k[1][3]],dtype=np.float32) for k in chan.rotationkeys]
if t <= times[0]: rot = vals[0]
elif t >= times[-1]: rot = vals[-1]
else:
for i in range(len(times)-1):
if times[i] <= t <= times[i+1]:
tt = (t-times[i])/(times[i+1]-times[i])
rot = slerp(vals[i], vals[i+1], tt); break
if getattr(chan, 'scalingkeys', None):
times = [k[0] for k in chan.scalingkeys]
vals = [np.array(k[1],dtype=np.float32) for k in chan.scalingkeys]
if t <= times[0]: scl = vals[0]
elif t >= times[-1]: scl = vals[-1]
else:
for i in range(len(times)-1):
if times[i] <= t <= times[i+1]:
tt = (t-times[i])/(times[i+1]-times[i])
scl = lerp(vals[i], vals[i+1], tt); break
return pos, rot, scl
# compute bone matrices each frame
def update(self, dt):
if self.current is None: return
tps = self.current['tps']; dur = self.current['duration']
self.anim_time += dt * tps
if self.loop: self.anim_time %= dur
if self.prev:
self.blend_time += dt
if self.blend_time >= self.blend_len:
self.prev = None; self.blend_time = 0.0
# recursive global transforms
globals_curr = {}
globals_prev = {}
def recurse(node, parent, anim):
# local transform: default from node.transformation unless channel present
local = mat4_from_list(node.transformation) if node.transformation is not None else identity4()
if anim:
ch = anim['channels'].get(node.name)
if ch:
p,r,s = self._eval_channel(ch, self.anim_time)
m = identity4()
if r is not None: m = m.dot(quat_to_mat4(r))
if p is not None: m = m.dot(translate_matrix(p))
if s is not None: m = m.dot(scale_matrix(s))
local = m
global_tf = parent.dot(local)
if node.name in self.bone_index:
globals_curr[self.bone_index[node.name]] = global_tf
for c in node.children: recurse(c, global_tf, anim)
recurse(self.scene.rootnode, identity4(), self.current)
if self.prev:
# use same time for prev for sync; could keep prev_time separately for more control
def recurse_prev(node, parent, anim):
local = mat4_from_list(node.transformation) if node.transformation is not None else identity4()
if anim:
ch = anim['channels'].get(node.name)
if ch:
p,r,s = self._eval_channel(ch, self.anim_time)
m = identity4()
if r is not None: m = m.dot(quat_to_mat4(r))
if p is not None: m = m.dot(translate_matrix(p))
if s is not None: m = m.dot(scale_matrix(s))
local = m
global_tf = parent.dot(local)
if node.name in self.bone_index:
globals_prev[self.bone_index[node.name]] = global_tf
for c in node.children: recurse_prev(c, global_tf, anim)
recurse_prev(self.scene.rootnode, identity4(), self.prev)
else:
globals_prev = globals_curr
# blend per-bone in TRS space for good rotation results
t = (self.blend_time / self.blend_len) if (self.prev and self.blend_len>0) else 1.0
for i in range(min(len(self.bone_names), self.max_bones)):
g = globals_curr.get(i, identity4())
gp = globals_prev.get(i, identity4())
# decompose mats into TRS approx by extracting translation and rotation from mat4
trans_g = g[:3,3]
trans_gp = gp[:3,3]
# rotation extraction (approx): convert 3x3 to quat via matrix -> quat (fast method)
def mat3_to_quat(m):
trace = m[0,0] + m[1,1] + m[2,2]
if trace > 0:
s = 0.5 / math.sqrt(trace + 1.0)
w = 0.25 / s
x = (m[2,1] - m[1,2]) * s
y = (m[0,2] - m[2,0]) * s
z = (m[1,0] - m[0,1]) * s
else:
if m[0,0] > m[1,1] and m[0,0] > m[2,2]:
s = 2.0 * math.sqrt(1.0 + m[0,0] - m[1,1] - m[2,2])
w = (m[2,1] - m[1,2]) / s
x = 0.25 * s
y = (m[0,1] + m[1,0]) / s
z = (m[0,2] + m[2,0]) / s
elif m[1,1] > m[2,2]:
s = 2.0 * math.sqrt(1.0 + m[1,1] - m[0,0] - m[2,2])
w = (m[0,2] - m[2,0]) / s
x = (m[0,1] + m[1,0]) / s
y = 0.25 * s
z = (m[1,2] + m[2,1]) / s
else:
s = 2.0 * math.sqrt(1.0 + m[2,2] - m[0,0] - m[1,1])
w = (m[1,0] - m[0,1]) / s
x = (m[0,2] + m[2,0]) / s
y = (m[1,2] + m[2,1]) / s
z = 0.25 * s
return np.array([x,y,z,w], dtype=np.float32)
rot_g = mat3_to_quat(g[:3,:3])
rot_gp = mat3_to_quat(gp[:3,:3])
s_g = np.array([np.cbrt(np.linalg.det(g[:3,:3]))]*3) if np.linalg.det(g[:3,:3])!=0 else np.array([1,1,1])
s_gp = np.array([np.cbrt(np.linalg.det(gp[:3,:3]))]*3) if np.linalg.det(gp[:3,:3])!=0 else np.array([1,1,1])
trans_blend = lerp(trans_gp, trans_g, t)
rot_blend = slerp(rot_gp, rot_g, t)
scl_blend = lerp(s_gp, s_g, t)
# compose local matrix: T * R * S
M = identity4()
M = M.dot(translate_matrix(trans_blend))
M = M.dot(quat_to_mat4(rot_blend))
M = M.dot(scale_matrix(scl_blend))
final = M.dot(self.inv_bind[i])
self.bone_matrices[i] = final
# pad remaining with identity
if len(self.bone_names) < self.max_bones:
for i in range(len(self.bone_names), self.max_bones):
self.bone_matrices[i] = identity4()
def render(self, proj, view, model=None):
if model is None: model = self.model
glUseProgram(self.program)
loc = glGetUniformLocation
glUniformMatrix4fv(loc(self.program, "u_proj"), 1, GL_FALSE, proj.T.astype(np.float32))
glUniformMatrix4fv(loc(self.program, "u_view"), 1, GL_FALSE, view.T.astype(np.float32))
glUniformMatrix4fv(loc(self.program, "u_model"), 1, GL_FALSE, model.T.astype(np.float32))
locb = glGetUniformLocation(self.program, "u_bones")
glUniformMatrix4fv(locb, self.max_bones, GL_FALSE, self.bone_matrices.reshape(-1).astype(np.float32))
for m in self.meshes:
glBindVertexArray(m['vao'])
glDrawElements(GL_TRIANGLES, m['count'], GL_UNSIGNED_INT, ctypes.c_void_p(0))
glBindVertexArray(0)
glUseProgram(0)
# ---------------- Example (GLFW) ----------------
def perspective(fovy, aspect, near, far):
f = 1.0 / math.tan(math.radians(fovy)/2.0)
m = np.zeros((4,4), dtype=np.float32)
m[0,0] = f / aspect; m[1,1] = f
m[2,2] = (far+near)/(near - far); m[2,3] = (2*far*near)/(near - far)
m[3,2] = -1.0
return m
def look_at(eye, center, up):
e = np.array(eye); c = np.array(center); u = np.array(up)
f = c - e; f /= np.linalg.norm(f)
s = np.cross(f,u); s /= np.linalg.norm(s)
u2 = np.cross(s,f)
M = identity4()
M[0,:3] = s; M[1,:3] = u2; M[2,:3] = -f
M[:3,3] = -M[:3,:3].dot(e)
return M
if __name__ == "__main__":
if not glfw.init(): raise SystemExit("glfw init failed")
w,h = 1280,720
glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR,3)
glfw.window_hint(glfw.CONTEXT_VERSION_MINOR,3)
glfw.window_hint(glfw.OPENGL_PROFILE,glfw.OPENGL_CORE_PROFILE)
win = glfw.create_window(w,h,"GLB Viewer", None, None)
glfw.make_context_current(win)
glEnable(GL_DEPTH_TEST)
player = GLBPlayer("model.glb", max_bones=80) # <--- put path to your GLB
print("Animations:", [a['name'] for a in player.anims])
player.play(0, blend=0.12) # start idle (index 0)
running = False
last = time.time()
def key_cb(window, key, sc, action, mods):
nonlocal running
if key == glfw.KEY_ESCAPE and action == glfw.PRESS: glfw.set_window_should_close(window, True)
if key == glfw.KEY_SPACE and action == glfw.PRESS:
running = not running
try:
if running:
player.play("Run", blend=0.12)
else:
player.play("Idle", blend=0.12)
except:
# fallback indices: 1 -> run, 0 -> idle
if running: player.play(1, blend=0.12)
else: player.play(0, blend=0.12)
glfw.set_key_callback(win, key_cb)
while not glfw.window_should_close(win):
now = time.time(); dt = now - last; last = now
player.update(dt)
glViewport(0,0,w,h); glClearColor(0.15,0.16,0.18,1.0); glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
proj = perspective(60.0, w/h, 0.1, 100.0)
view = look_at([0,1.5,4.0], [0,1.0,0], [0,1,0])
player.render(proj, view)
glfw.swap_buffers(win); glfw.poll_events()
glfw.terminate()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment