Kelsidavis-WoWee/tools/m2_viewer.py
Kelsi 55faacef96 Add M2/WMO 3D viewer with textured rendering, animation, and audio playback
- New tools/m2_viewer.py: Pygame/OpenGL viewer for M2 models (textured
  rendering, skeletal animation, orbit camera) and WMO buildings
- M2 viewer: per-batch texture mapping, CPU vertex skinning, animation
  playback with play/pause/speed controls, wireframe overlay toggle
- WMO viewer: root+group file parsing (MOTX/MOMT/MOVT/MOVI/MONR/MOTV/MOBA),
  per-batch material rendering with BLP textures
- Asset browser: "Open 3D Viewer" buttons for M2 and WMO previews,
  audio Play/Stop buttons using pygame.mixer in subprocess
- Handles both WotLK (v264) and Vanilla (v256) M2 formats
2026-02-23 22:22:39 -08:00

2170 lines
84 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Self-contained Pygame/OpenGL M2 model viewer.
Launched as a subprocess from the asset pipeline GUI to avoid Tkinter/Pygame conflicts.
Supports textured rendering, skeletal animation playback, and orbit camera controls.
"""
from __future__ import annotations
import hashlib
import math
import multiprocessing
import os
import shutil
import struct
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
# ---------------------------------------------------------------------------
# Matrix math utilities (pure NumPy, no external 3D lib needed)
# ---------------------------------------------------------------------------
def perspective(fov_deg: float, aspect: float, near: float, far: float) -> np.ndarray:
f = 1.0 / math.tan(math.radians(fov_deg) / 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.0 * far * near) / (near - far)
m[3, 2] = -1.0
return m
def look_at(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray:
f = target - eye
f = f / np.linalg.norm(f)
s = np.cross(f, up)
s = s / (np.linalg.norm(s) + 1e-12)
u = np.cross(s, f)
m = np.eye(4, dtype=np.float32)
m[0, :3] = s
m[1, :3] = u
m[2, :3] = -f
m[0, 3] = -np.dot(s, eye)
m[1, 3] = -np.dot(u, eye)
m[2, 3] = np.dot(f, eye)
return m
def translate(tx: float, ty: float, tz: float) -> np.ndarray:
m = np.eye(4, dtype=np.float32)
m[0, 3] = tx
m[1, 3] = ty
m[2, 3] = tz
return m
def scale_mat4(sx: float, sy: float, sz: float) -> np.ndarray:
m = np.eye(4, dtype=np.float32)
m[0, 0] = sx
m[1, 1] = sy
m[2, 2] = sz
return m
def quat_to_mat4(q: np.ndarray) -> np.ndarray:
"""Quaternion (x,y,z,w) to 4x4 rotation matrix."""
x, y, z, w = q
m = np.eye(4, dtype=np.float32)
m[0, 0] = 1 - 2 * (y * y + z * z)
m[0, 1] = 2 * (x * y - z * w)
m[0, 2] = 2 * (x * z + y * w)
m[1, 0] = 2 * (x * y + z * w)
m[1, 1] = 1 - 2 * (x * x + z * z)
m[1, 2] = 2 * (y * z - x * w)
m[2, 0] = 2 * (x * z - y * w)
m[2, 1] = 2 * (y * z + x * w)
m[2, 2] = 1 - 2 * (x * x + y * y)
return m
def slerp(q0: np.ndarray, q1: np.ndarray, t: float) -> np.ndarray:
dot = np.dot(q0, q1)
if dot < 0:
q1 = -q1
dot = -dot
dot = min(dot, 1.0)
if dot > 0.9995:
result = q0 + t * (q1 - q0)
return result / np.linalg.norm(result)
theta = math.acos(dot)
sin_theta = math.sin(theta)
a = math.sin((1 - t) * theta) / sin_theta
b = math.sin(t * theta) / sin_theta
result = a * q0 + b * q1
return result / np.linalg.norm(result)
# ---------------------------------------------------------------------------
# M2 Parser
# ---------------------------------------------------------------------------
@dataclass
class M2Track:
"""Parsed animation track with per-sequence timestamps and keyframes."""
interp: int = 0
global_sequence: int = -1
timestamps: list[np.ndarray] = field(default_factory=list) # list of uint32 arrays per seq
keys: list[np.ndarray] = field(default_factory=list) # list of value arrays per seq
@dataclass
class M2Bone:
key_bone_id: int = -1
flags: int = 0
parent: int = -1
pivot: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float32))
translation: M2Track = field(default_factory=M2Track)
rotation: M2Track = field(default_factory=M2Track)
scale: M2Track = field(default_factory=M2Track)
@dataclass
class M2Submesh:
vertex_start: int = 0
vertex_count: int = 0
index_start: int = 0
index_count: int = 0
@dataclass
class M2Batch:
submesh_index: int = 0
texture_combo_index: int = 0
@dataclass
class M2Animation:
anim_id: int = 0
variation: int = 0
duration: int = 0
speed: float = 0.0
flags: int = 0
class M2Parser:
"""Parse M2 binary data for rendering: vertices, UVs, normals, bones, skins, textures."""
def __init__(self, data: bytes):
self.data = data
self.version = struct.unpack_from("<I", data, 4)[0]
self.is_vanilla = self.version <= 256
# Parsed data
self.positions: np.ndarray = np.empty((0, 3), dtype=np.float32)
self.normals: np.ndarray = np.empty((0, 3), dtype=np.float32)
self.uvs: np.ndarray = np.empty((0, 2), dtype=np.float32)
self.bone_weights: np.ndarray = np.empty((0, 4), dtype=np.uint8)
self.bone_indices: np.ndarray = np.empty((0, 4), dtype=np.uint8)
self.vertex_lookup: np.ndarray = np.empty(0, dtype=np.uint16)
self.triangles: np.ndarray = np.empty(0, dtype=np.uint16)
self.resolved_indices: np.ndarray = np.empty(0, dtype=np.uint16) # global vertex indices
self.submeshes: list[M2Submesh] = []
self.batches: list[M2Batch] = []
self.textures: list[dict] = [] # {type, flags, filename}
self.texture_lookup: list[int] = []
self.bone_lookup: list[int] = []
self.bones: list[M2Bone] = []
self.animations: list[M2Animation] = []
self.global_sequences: list[int] = []
self._parse()
def _hdr(self, field_name: str) -> int:
"""Return header offset for a given field, version-gated."""
offsets_wotlk = {
"nGlobalSeq": 20, "ofsGlobalSeq": 24,
"nAnims": 28, "ofsAnims": 32,
"nBones": 44, "ofsBones": 48,
"nVerts": 60, "ofsVerts": 64,
"nTextures": 80, "ofsTextures": 84,
"nTextureLookup": 128, "ofsTextureLookup": 132,
"nBoneLookup": 120, "ofsBoneLookup": 124,
}
offsets_vanilla = {
"nGlobalSeq": 20, "ofsGlobalSeq": 24,
"nAnims": 28, "ofsAnims": 32,
"nBones": 52, "ofsBones": 56,
"nVerts": 68, "ofsVerts": 72,
"nTextures": 92, "ofsTextures": 96,
"nTextureLookup": 148, "ofsTextureLookup": 152,
"nBoneLookup": 140, "ofsBoneLookup": 144,
}
table = offsets_vanilla if self.is_vanilla else offsets_wotlk
return table[field_name]
def _read_u32(self, offset: int) -> int:
return struct.unpack_from("<I", self.data, offset)[0]
def _read_m2array(self, field_name: str) -> tuple[int, int]:
"""Read count, offset for an M2Array header field."""
n_off = self._hdr(f"n{field_name}")
o_off = self._hdr(f"ofs{field_name}")
n = self._read_u32(n_off)
o = self._read_u32(o_off)
return n, o
def _parse(self):
self._parse_global_sequences()
self._parse_vertices()
self._parse_textures()
self._parse_texture_lookup()
self._parse_bone_lookup()
self._parse_animations()
self._parse_bones()
self._parse_skin()
def _parse_global_sequences(self):
n, ofs = self._read_m2array("GlobalSeq")
if n == 0 or n > 10000 or ofs + n * 4 > len(self.data):
return
self.global_sequences = list(struct.unpack_from(f"<{n}I", self.data, ofs))
def _parse_vertices(self):
n, ofs = self._read_m2array("Verts")
if n == 0 or n > 500000 or ofs + n * 48 > len(self.data):
return
# Parse all vertex fields using numpy for speed
positions = np.empty((n, 3), dtype=np.float32)
normals = np.empty((n, 3), dtype=np.float32)
uvs = np.empty((n, 2), dtype=np.float32)
bone_weights = np.empty((n, 4), dtype=np.uint8)
bone_indices = np.empty((n, 4), dtype=np.uint8)
for i in range(n):
base = ofs + i * 48
positions[i] = struct.unpack_from("<3f", self.data, base)
bone_weights[i] = struct.unpack_from("<4B", self.data, base + 12)
bone_indices[i] = struct.unpack_from("<4B", self.data, base + 16)
normals[i] = struct.unpack_from("<3f", self.data, base + 20)
uvs[i] = struct.unpack_from("<2f", self.data, base + 32)
self.positions = positions
self.normals = normals
self.uvs = uvs
self.bone_weights = bone_weights
self.bone_indices = bone_indices
def _parse_textures(self):
n, ofs = self._read_m2array("Textures")
if n == 0 or n > 1000 or ofs + n * 16 > len(self.data):
return
for i in range(n):
base = ofs + i * 16
tex_type, tex_flags = struct.unpack_from("<II", self.data, base)
name_len, name_ofs = struct.unpack_from("<II", self.data, base + 8)
filename = ""
if tex_type == 0 and name_len > 1 and name_ofs + name_len <= len(self.data):
raw = self.data[name_ofs:name_ofs + name_len]
filename = raw.split(b"\x00", 1)[0].decode("ascii", errors="replace")
self.textures.append({"type": tex_type, "flags": tex_flags, "filename": filename})
def _parse_texture_lookup(self):
n, ofs = self._read_m2array("TextureLookup")
if n == 0 or n > 10000 or ofs + n * 2 > len(self.data):
return
self.texture_lookup = list(struct.unpack_from(f"<{n}H", self.data, ofs))
def _parse_bone_lookup(self):
n, ofs = self._read_m2array("BoneLookup")
if n == 0 or n > 10000 or ofs + n * 2 > len(self.data):
return
self.bone_lookup = list(struct.unpack_from(f"<{n}H", self.data, ofs))
def _parse_animations(self):
n, ofs = self._read_m2array("Anims")
if n == 0 or n > 5000:
return
seq_size = 68 if self.is_vanilla else 64
if ofs + n * seq_size > len(self.data):
return
for i in range(n):
base = ofs + i * seq_size
anim_id, variation = struct.unpack_from("<HH", self.data, base)
if self.is_vanilla:
start_ts, end_ts = struct.unpack_from("<II", self.data, base + 4)
duration = end_ts - start_ts
speed = struct.unpack_from("<f", self.data, base + 12)[0]
flags = struct.unpack_from("<I", self.data, base + 16)[0]
else:
duration = struct.unpack_from("<I", self.data, base + 4)[0]
speed = struct.unpack_from("<f", self.data, base + 8)[0]
flags = struct.unpack_from("<I", self.data, base + 12)[0]
self.animations.append(M2Animation(
anim_id=anim_id, variation=variation,
duration=duration, speed=speed, flags=flags,
))
def _parse_track_wotlk(self, base: int, key_size: int, key_dtype: str) -> M2Track:
"""Parse a WotLK M2TrackDisk (20 bytes) at given offset."""
track = M2Track()
if base + 20 > len(self.data):
return track
interp, global_seq = struct.unpack_from("<hh", self.data, base)
n_ts, ofs_ts, n_keys, ofs_keys = struct.unpack_from("<IIII", self.data, base + 4)
track.interp = interp
track.global_sequence = global_seq
if n_ts > 5000 or n_keys > 5000:
return track
# Each entry in n_ts is a sub-array header: {count(4), offset(4)}
for s in range(n_ts):
ts_hdr = ofs_ts + s * 8
if ts_hdr + 8 > len(self.data):
track.timestamps.append(np.empty(0, dtype=np.uint32))
continue
sub_count, sub_ofs = struct.unpack_from("<II", self.data, ts_hdr)
if sub_count > 50000 or sub_ofs + sub_count * 4 > len(self.data):
track.timestamps.append(np.empty(0, dtype=np.uint32))
continue
ts_data = np.frombuffer(self.data, dtype=np.uint32, count=sub_count, offset=sub_ofs)
track.timestamps.append(ts_data.copy())
for s in range(n_keys):
key_hdr = ofs_keys + s * 8
if key_hdr + 8 > len(self.data):
track.keys.append(np.empty(0, dtype=np.float32))
continue
sub_count, sub_ofs = struct.unpack_from("<II", self.data, key_hdr)
if sub_count > 50000 or sub_ofs + sub_count * key_size > len(self.data):
track.keys.append(np.empty(0, dtype=np.float32))
continue
if key_dtype == "compressed_quat":
raw = np.frombuffer(self.data, dtype=np.int16, count=sub_count * 4, offset=sub_ofs)
raw = raw.reshape(sub_count, 4).astype(np.float32)
# Decompress: (v < 0 ? v+32768 : v-32767) / 32767.0
result = np.where(raw < 0, raw + 32768.0, raw - 32767.0) / 32767.0
# Normalize each quaternion
norms = np.linalg.norm(result, axis=1, keepdims=True)
norms = np.maximum(norms, 1e-10)
result = result / norms
track.keys.append(result)
elif key_dtype == "vec3":
vals = np.frombuffer(self.data, dtype=np.float32, count=sub_count * 3, offset=sub_ofs)
track.keys.append(vals.reshape(sub_count, 3).copy())
elif key_dtype == "float":
vals = np.frombuffer(self.data, dtype=np.float32, count=sub_count, offset=sub_ofs)
track.keys.append(vals.copy())
return track
def _parse_track_vanilla(self, base: int, key_size: int, key_dtype: str) -> M2Track:
"""Parse a Vanilla M2TrackDiskVanilla (28 bytes) — flat arrays with M2Range indexing."""
track = M2Track()
if base + 28 > len(self.data):
return track
interp, global_seq = struct.unpack_from("<hh", self.data, base)
n_ranges, ofs_ranges = struct.unpack_from("<II", self.data, base + 4)
n_ts, ofs_ts = struct.unpack_from("<II", self.data, base + 12)
n_keys, ofs_keys = struct.unpack_from("<II", self.data, base + 20)
track.interp = interp
track.global_sequence = global_seq
if n_ts > 500000 or n_keys > 500000:
return track
# Read flat timestamp array
all_ts = np.empty(0, dtype=np.uint32)
if n_ts > 0 and ofs_ts + n_ts * 4 <= len(self.data):
all_ts = np.frombuffer(self.data, dtype=np.uint32, count=n_ts, offset=ofs_ts).copy()
# Read flat key array
if key_dtype == "c4quat":
all_keys_flat = np.empty(0, dtype=np.float32)
if n_keys > 0 and ofs_keys + n_keys * 16 <= len(self.data):
all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys * 4, offset=ofs_keys)
all_keys_flat = all_keys_flat.reshape(n_keys, 4).copy()
elif key_dtype == "vec3":
all_keys_flat = np.empty((0, 3), dtype=np.float32)
if n_keys > 0 and ofs_keys + n_keys * 12 <= len(self.data):
all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys * 3, offset=ofs_keys)
all_keys_flat = all_keys_flat.reshape(n_keys, 3).copy()
else:
all_keys_flat = np.empty(0, dtype=np.float32)
if n_keys > 0 and ofs_keys + n_keys * key_size <= len(self.data):
all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys, offset=ofs_keys).copy()
# Read ranges and split into per-sequence arrays
if n_ranges > 0 and n_ranges < 5000 and ofs_ranges + n_ranges * 8 <= len(self.data):
for r in range(n_ranges):
rng_start, rng_end = struct.unpack_from("<II", self.data, ofs_ranges + r * 8)
if rng_end > rng_start and rng_end <= len(all_ts):
track.timestamps.append(all_ts[rng_start:rng_end])
if key_dtype in ("c4quat", "vec3") and rng_end <= len(all_keys_flat):
track.keys.append(all_keys_flat[rng_start:rng_end])
elif rng_end <= len(all_keys_flat):
track.keys.append(all_keys_flat[rng_start:rng_end])
else:
track.keys.append(np.empty(0, dtype=np.float32))
else:
track.timestamps.append(np.empty(0, dtype=np.uint32))
track.keys.append(np.empty(0, dtype=np.float32))
else:
# No ranges — treat entire array as single sequence
if len(all_ts) > 0:
track.timestamps.append(all_ts)
track.keys.append(all_keys_flat if len(all_keys_flat) > 0 else np.empty(0, dtype=np.float32))
return track
def _parse_bones(self):
n, ofs = self._read_m2array("Bones")
if n == 0 or n > 5000:
return
if self.is_vanilla:
bone_size = 108 # No boneNameCRC, 28-byte tracks: 4+4+2+2+3×28+12=108
for i in range(n):
base = ofs + i * bone_size
if base + bone_size > len(self.data):
break
bone = M2Bone()
bone.key_bone_id = struct.unpack_from("<i", self.data, base)[0]
bone.flags = struct.unpack_from("<I", self.data, base + 4)[0]
bone.parent = struct.unpack_from("<h", self.data, base + 8)[0]
# submeshId at +10 (2 bytes), then 3 vanilla tracks at +12, each 28 bytes
bone.translation = self._parse_track_vanilla(base + 12, 12, "vec3")
bone.rotation = self._parse_track_vanilla(base + 40, 16, "c4quat")
bone.scale = self._parse_track_vanilla(base + 68, 12, "vec3")
# pivot at 12 + 3×28 = 96
bone.pivot = np.array(struct.unpack_from("<3f", self.data, base + 96), dtype=np.float32)
self.bones.append(bone)
else:
bone_size = 88 # WotLK with boneNameCRC
for i in range(n):
base = ofs + i * bone_size
if base + bone_size > len(self.data):
break
bone = M2Bone()
bone.key_bone_id = struct.unpack_from("<i", self.data, base)[0]
bone.flags = struct.unpack_from("<I", self.data, base + 4)[0]
bone.parent = struct.unpack_from("<h", self.data, base + 8)[0]
# submeshId(2) + boneNameCRC(4) = 6 more bytes before tracks
# Tracks start at base+16, each 20 bytes
bone.translation = self._parse_track_wotlk(base + 16, 12, "vec3")
bone.rotation = self._parse_track_wotlk(base + 36, 8, "compressed_quat")
bone.scale = self._parse_track_wotlk(base + 56, 12, "vec3")
bone.pivot = np.array(struct.unpack_from("<3f", self.data, base + 76), dtype=np.float32)
self.bones.append(bone)
def _parse_skin(self):
"""Parse skin file (external .skin or embedded for vanilla)."""
# This will be called externally with skin data
pass
def parse_skin_data(self, skin_data: bytes):
"""Parse skin file binary data for vertex lookup, triangles, submeshes, batches."""
if len(skin_data) < 48:
return
off = 0
if skin_data[:4] == b"SKIN":
off = 4
n_indices, ofs_indices = struct.unpack_from("<II", skin_data, off + 0)
n_tris, ofs_tris = struct.unpack_from("<II", skin_data, off + 8)
# Properties at +16
n_submeshes, ofs_submeshes = struct.unpack_from("<II", skin_data, off + 24)
n_batches, ofs_batches = struct.unpack_from("<II", skin_data, off + 32)
if n_indices == 0 or n_indices > 500000:
return
if n_tris == 0 or n_tris > 500000:
return
# Vertex lookup
if ofs_indices + n_indices * 2 <= len(skin_data):
self.vertex_lookup = np.frombuffer(skin_data, dtype=np.uint16,
count=n_indices, offset=ofs_indices).copy()
# Raw triangle indices (indices into vertex_lookup)
if ofs_tris + n_tris * 2 <= len(skin_data):
self.triangles = np.frombuffer(skin_data, dtype=np.uint16,
count=n_tris, offset=ofs_tris).copy()
# Resolve two-level indirection: triangle idx -> vertex_lookup -> global vertex idx
# This matches the C++ approach: model.indices stores global vertex indices
if len(self.triangles) > 0 and len(self.vertex_lookup) > 0:
n_verts = len(self.positions) if len(self.positions) > 0 else 65536
resolved = np.zeros(len(self.triangles), dtype=np.uint16)
for i, tri_idx in enumerate(self.triangles):
if tri_idx < len(self.vertex_lookup):
global_idx = self.vertex_lookup[tri_idx]
resolved[i] = global_idx if global_idx < n_verts else 0
else:
resolved[i] = 0
self.resolved_indices = resolved
# Submeshes (WotLK: 48 bytes, Vanilla: 32 bytes)
submesh_size = 32 if self.is_vanilla else 48
if n_submeshes > 0 and n_submeshes < 10000 and ofs_submeshes + n_submeshes * submesh_size <= len(skin_data):
for i in range(n_submeshes):
base = ofs_submeshes + i * submesh_size
sm = M2Submesh()
# WotLK M2SkinSection: +0=skinSectionId(2), +2=Level(2),
# +4=vertexStart(2), +6=vertexCount(2), +8=indexStart(2), +10=indexCount(2)
sm.vertex_start = struct.unpack_from("<H", skin_data, base + 4)[0]
sm.vertex_count = struct.unpack_from("<H", skin_data, base + 6)[0]
sm.index_start = struct.unpack_from("<H", skin_data, base + 8)[0]
sm.index_count = struct.unpack_from("<H", skin_data, base + 10)[0]
self.submeshes.append(sm)
# Batches (24 bytes each)
if n_batches > 0 and n_batches < 10000 and ofs_batches + n_batches * 24 <= len(skin_data):
for i in range(n_batches):
base = ofs_batches + i * 24
batch = M2Batch()
# M2Batch: flags(1) + priority(1) + shaderId(2) + skinSectionIndex(2)
# + geosetIndex(2) + colorIndex(2) + materialIndex(2) + materialLayer(2)
# + textureCount(2) + textureComboIndex(2) + ...
batch.submesh_index = struct.unpack_from("<H", skin_data, base + 4)[0]
batch.texture_combo_index = struct.unpack_from("<H", skin_data, base + 16)[0]
self.batches.append(batch)
# ---------------------------------------------------------------------------
# Animation System
# ---------------------------------------------------------------------------
_ANIM_NAMES: dict[int, str] = {
0: "Stand", 1: "Death", 2: "Spell", 3: "Stop", 4: "Walk", 5: "Run",
6: "Dead", 7: "Rise", 8: "StandWound", 9: "CombatWound", 10: "CombatCritical",
11: "ShuffleLeft", 12: "ShuffleRight", 13: "Walkbackwards", 14: "Stun",
15: "HandsClosed", 16: "AttackUnarmed", 17: "Attack1H", 18: "Attack2H",
24: "ShieldBlock", 25: "ReadyUnarmed", 26: "Ready1H",
27: "Ready2H", 34: "NPCWelcome", 35: "NPCGoodbye",
37: "JumpStart", 38: "Jump", 39: "JumpEnd", 40: "Fall",
41: "SwimIdle", 42: "Swim", 60: "SpellChannelDirected",
69: "CombatAbility", 138: "Fly", 157: "EmoteTalk", 185: "FlyIdle",
}
class AnimationSystem:
"""Evaluates bone hierarchy each frame, producing world-space bone matrices."""
def __init__(self, parser: M2Parser):
self.parser = parser
self.bone_matrices: np.ndarray = np.empty(0)
self.current_seq: int = 0
self.playing: bool = True
self.speed: float = 1.0
self.time_ms: float = 0.0
self._identity = np.eye(4, dtype=np.float32)
def set_sequence(self, idx: int):
self.current_seq = max(0, min(idx, len(self.parser.animations) - 1))
self.time_ms = 0.0
def update(self, dt: float):
"""Advance animation time and compute bone matrices."""
if not self.parser.bones:
return
n_bones = len(self.parser.bones)
if len(self.bone_matrices) == 0:
self.bone_matrices = np.tile(self._identity, (n_bones, 1, 1)).copy()
if self.playing and self.parser.animations:
anim = self.parser.animations[self.current_seq]
if anim.duration > 0:
self.time_ms += dt * 1000.0 * self.speed
self.time_ms = self.time_ms % anim.duration
seq_idx = self.current_seq
t = self.time_ms
for i, bone in enumerate(self.parser.bones):
local = self._eval_bone(bone, seq_idx, t)
if bone.parent >= 0 and bone.parent < n_bones:
self.bone_matrices[i] = self.bone_matrices[bone.parent] @ local
else:
self.bone_matrices[i] = local
def _eval_bone(self, bone: M2Bone, seq_idx: int, time_ms: float) -> np.ndarray:
"""Compute local bone transform for one bone at given time."""
trans = self._interp_vec3(bone.translation, seq_idx, time_ms, np.zeros(3, dtype=np.float32))
rot = self._interp_quat(bone.rotation, seq_idx, time_ms)
scl = self._interp_vec3(bone.scale, seq_idx, time_ms, np.ones(3, dtype=np.float32))
# local = T(pivot) * T(trans) * R(rot) * S(scl) * T(-pivot)
p = bone.pivot
m = translate(p[0], p[1], p[2])
m = m @ translate(trans[0], trans[1], trans[2])
m = m @ quat_to_mat4(rot)
m = m @ scale_mat4(scl[0], scl[1], scl[2])
m = m @ translate(-p[0], -p[1], -p[2])
return m
def _get_time_and_seq(self, track: M2Track, seq_idx: int, time_ms: float) -> tuple[int, float]:
"""Resolve sequence index and time, handling global sequences."""
if track.global_sequence >= 0 and track.global_sequence < len(self.parser.global_sequences):
gs_dur = self.parser.global_sequences[track.global_sequence]
actual_seq = 0
actual_time = time_ms % gs_dur if gs_dur > 0 else 0
else:
actual_seq = seq_idx
actual_time = time_ms
return actual_seq, actual_time
def _interp_vec3(self, track: M2Track, seq_idx: int, time_ms: float,
default: np.ndarray) -> np.ndarray:
si, t = self._get_time_and_seq(track, seq_idx, time_ms)
if si >= len(track.timestamps) or si >= len(track.keys):
return default
ts = track.timestamps[si]
keys = track.keys[si]
if len(ts) == 0 or len(keys) == 0:
return default
if len(keys.shape) == 1:
return default
if t <= ts[0]:
return keys[0]
if t >= ts[-1]:
return keys[-1]
# Binary search
idx = np.searchsorted(ts, t, side='right') - 1
idx = max(0, min(idx, len(ts) - 2))
t0, t1 = float(ts[idx]), float(ts[idx + 1])
frac = (t - t0) / (t1 - t0) if t1 != t0 else 0.0
if track.interp == 0:
return keys[idx]
return keys[idx] * (1.0 - frac) + keys[idx + 1] * frac
def _interp_quat(self, track: M2Track, seq_idx: int, time_ms: float) -> np.ndarray:
default = np.array([0, 0, 0, 1], dtype=np.float32)
si, t = self._get_time_and_seq(track, seq_idx, time_ms)
if si >= len(track.timestamps) or si >= len(track.keys):
return default
ts = track.timestamps[si]
keys = track.keys[si]
if len(ts) == 0 or len(keys) == 0:
return default
if len(keys.shape) == 1:
return default
if t <= ts[0]:
return keys[0]
if t >= ts[-1]:
return keys[-1]
idx = np.searchsorted(ts, t, side='right') - 1
idx = max(0, min(idx, len(ts) - 2))
t0, t1 = float(ts[idx]), float(ts[idx + 1])
frac = (t - t0) / (t1 - t0) if t1 != t0 else 0.0
if track.interp == 0:
return keys[idx]
return slerp(keys[idx], keys[idx + 1], frac)
def skin_vertices(self, positions: np.ndarray, bone_weights: np.ndarray,
bone_indices: np.ndarray, bone_lookup: list[int]) -> np.ndarray:
"""CPU vertex skinning (NumPy vectorized). Returns transformed positions."""
if len(self.bone_matrices) == 0 or len(bone_lookup) == 0:
return positions.copy()
n = len(positions)
n_bones = len(self.bone_matrices)
n_lookup = len(bone_lookup)
lookup_arr = np.array(bone_lookup, dtype=np.int32)
# Build homogeneous positions (n, 4)
pos4 = np.ones((n, 4), dtype=np.float32)
pos4[:, :3] = positions
# Weights normalized to float (n, 4)
weights = bone_weights.astype(np.float32) / 255.0
result = np.zeros((n, 4), dtype=np.float32)
for j in range(4):
w = weights[:, j] # (n,)
mask = w > 0.001
if not np.any(mask):
continue
bi = bone_indices[mask, j].astype(np.int32)
# Clamp bone lookup indices
valid = bi < n_lookup
bi = np.where(valid, bi, 0)
global_bones = lookup_arr[bi]
global_bones = np.where(valid, global_bones, 0)
valid2 = valid & (global_bones < n_bones)
global_bones = np.where(valid2, global_bones, 0)
# Gather bone matrices for these vertices: (count, 4, 4)
mats = self.bone_matrices[global_bones]
# Transform: (count, 4, 4) @ (count, 4, 1) -> (count, 4, 1)
transformed = np.einsum('nij,nj->ni', mats, pos4[mask])
# Apply weight and validity
weighted = transformed * w[mask, np.newaxis]
weighted[~valid2] = 0
result[mask] += weighted
# De-homogenize
w_col = result[:, 3:4]
w_col = np.where(np.abs(w_col) > 0.001, w_col, 1.0)
return (result[:, :3] / w_col).astype(np.float32)
# ---------------------------------------------------------------------------
# Orbit Camera
# ---------------------------------------------------------------------------
class OrbitCamera:
def __init__(self):
self.azimuth: float = 0.0
self.elevation: float = 0.3
self.distance: float = 5.0
self.target: np.ndarray = np.zeros(3, dtype=np.float32)
self.pan_x: float = 0.0
self.pan_y: float = 0.0
def get_view_matrix(self) -> np.ndarray:
eye = self._eye_pos()
up = np.array([0, 0, 1], dtype=np.float32)
target = self.target + np.array([self.pan_x, self.pan_y, 0], dtype=np.float32)
return look_at(eye, target, up)
def _eye_pos(self) -> np.ndarray:
x = self.distance * math.cos(self.elevation) * math.cos(self.azimuth)
y = self.distance * math.cos(self.elevation) * math.sin(self.azimuth)
z = self.distance * math.sin(self.elevation)
target = self.target + np.array([self.pan_x, self.pan_y, 0], dtype=np.float32)
return target + np.array([x, y, z], dtype=np.float32)
def orbit(self, dx: float, dy: float):
self.azimuth += dx * 0.01
self.elevation = max(-math.pi / 2 + 0.01, min(math.pi / 2 - 0.01,
self.elevation + dy * 0.01))
def zoom(self, delta: float):
self.distance = max(0.5, self.distance * (1.0 - delta * 0.1))
def pan(self, dx: float, dy: float):
self.pan_x += dx * self.distance * 0.002
self.pan_y += dy * self.distance * 0.002
# ---------------------------------------------------------------------------
# M2 Renderer (OpenGL 3.3)
# ---------------------------------------------------------------------------
VERT_SHADER = """
#version 330 core
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNormal;
layout(location=2) in vec2 aUV;
uniform mat4 uMVP;
uniform mat4 uModel;
out vec3 vNormal;
out vec2 vUV;
out vec3 vWorldPos;
void main() {
gl_Position = uMVP * vec4(aPos, 1.0);
vNormal = mat3(uModel) * aNormal;
vUV = aUV;
vWorldPos = (uModel * vec4(aPos, 1.0)).xyz;
}
"""
FRAG_SHADER = """
#version 330 core
in vec3 vNormal;
in vec2 vUV;
in vec3 vWorldPos;
uniform sampler2D uTexture;
uniform int uHasTexture;
uniform vec3 uLightDir;
out vec4 FragColor;
void main() {
vec3 N = normalize(vNormal);
float NdotL = abs(dot(N, uLightDir));
float ambient = 0.35;
float diffuse = 0.65 * NdotL;
float light = ambient + diffuse;
vec4 texColor;
if (uHasTexture == 1) {
texColor = texture(uTexture, vUV);
if (texColor.a < 0.1) discard;
} else {
texColor = vec4(0.6, 0.6, 0.65, 1.0);
}
FragColor = vec4(texColor.rgb * light, texColor.a);
}
"""
WIRE_VERT = """
#version 330 core
layout(location=0) in vec3 aPos;
uniform mat4 uMVP;
void main() {
gl_Position = uMVP * vec4(aPos, 1.0);
}
"""
WIRE_FRAG = """
#version 330 core
out vec4 FragColor;
void main() {
FragColor = vec4(0.0, 0.8, 1.0, 0.4);
}
"""
class M2Renderer:
"""OpenGL 3.3 renderer for M2 models."""
def __init__(self, parser: M2Parser, blp_paths: dict[str, str], blp_convert: str):
self.parser = parser
self.blp_paths = blp_paths # texture filename -> filesystem path
self.blp_convert_path = blp_convert
self.vao = 0
self.vbo = 0
self.ebo = 0
self.wire_vao = 0
self.wire_vbo = 0
self.wire_ebo = 0
self.shader = 0
self.wire_shader = 0
self.gl_textures: dict[int, int] = {} # batch index -> GL texture ID
self.batch_texture_map: dict[int, int] = {} # batch idx -> texture array index
self.show_wireframe = False
self.n_indices = 0
self.n_wire_indices = 0
self.n_verts = 0
def init_gl(self):
import OpenGL.GL as gl
self._gl = gl
# Build shaders
self.shader = self._compile_program(VERT_SHADER, FRAG_SHADER)
self.wire_shader = self._compile_program(WIRE_VERT, WIRE_FRAG)
p = self.parser
n_verts = len(p.positions)
if n_verts == 0:
return
self.n_verts = n_verts
# VBO: ALL model vertices, interleaved pos(12) + normal(12) + uv(8) = 32 bytes
vbo_data = np.zeros((n_verts, 8), dtype=np.float32)
vbo_data[:, 0:3] = p.positions
vbo_data[:, 3:6] = p.normals if len(p.normals) == n_verts else np.zeros((n_verts, 3), dtype=np.float32)
vbo_data[:, 6:8] = p.uvs if len(p.uvs) == n_verts else np.zeros((n_verts, 2), dtype=np.float32)
# EBO: resolved global vertex indices (after two-level skin indirection)
if len(p.resolved_indices) > 0:
idx_data = p.resolved_indices.astype(np.uint16)
elif len(p.triangles) > 0:
idx_data = p.triangles.astype(np.uint16)
else:
idx_data = np.empty(0, dtype=np.uint16)
# Create main VAO/VBO/EBO
self.vao = gl.glGenVertexArrays(1)
self.vbo = gl.glGenBuffers(1)
self.ebo = gl.glGenBuffers(1)
gl.glBindVertexArray(self.vao)
gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo)
gl.glBufferData(gl.GL_ARRAY_BUFFER, vbo_data.nbytes, vbo_data, gl.GL_DYNAMIC_DRAW)
if len(idx_data) > 0:
self.n_indices = len(idx_data)
gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.ebo)
gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW)
stride = 32
gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(0))
gl.glEnableVertexAttribArray(0)
gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(12))
gl.glEnableVertexAttribArray(1)
gl.glVertexAttribPointer(2, 2, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(24))
gl.glEnableVertexAttribArray(2)
gl.glBindVertexArray(0)
# Wireframe VAO (positions only, same indices)
self.wire_vao = gl.glGenVertexArrays(1)
self.wire_vbo = gl.glGenBuffers(1)
self.wire_ebo = gl.glGenBuffers(1)
gl.glBindVertexArray(self.wire_vao)
gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.wire_vbo)
gl.glBufferData(gl.GL_ARRAY_BUFFER, p.positions.nbytes, p.positions, gl.GL_DYNAMIC_DRAW)
if len(idx_data) > 0:
self.n_wire_indices = len(idx_data)
gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.wire_ebo)
gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW)
gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, 12, gl.ctypes.c_void_p(0))
gl.glEnableVertexAttribArray(0)
gl.glBindVertexArray(0)
# Load textures
self._load_textures()
# Map batches to textures
self._map_batch_textures()
def _compile_program(self, vert_src: str, frag_src: str) -> int:
gl = self._gl
vs = gl.glCreateShader(gl.GL_VERTEX_SHADER)
gl.glShaderSource(vs, vert_src)
gl.glCompileShader(vs)
if gl.glGetShaderiv(vs, gl.GL_COMPILE_STATUS) != gl.GL_TRUE:
log = gl.glGetShaderInfoLog(vs).decode()
print(f"Vertex shader error: {log}")
fs = gl.glCreateShader(gl.GL_FRAGMENT_SHADER)
gl.glShaderSource(fs, frag_src)
gl.glCompileShader(fs)
if gl.glGetShaderiv(fs, gl.GL_COMPILE_STATUS) != gl.GL_TRUE:
log = gl.glGetShaderInfoLog(fs).decode()
print(f"Fragment shader error: {log}")
prog = gl.glCreateProgram()
gl.glAttachShader(prog, vs)
gl.glAttachShader(prog, fs)
gl.glLinkProgram(prog)
if gl.glGetProgramiv(prog, gl.GL_LINK_STATUS) != gl.GL_TRUE:
log = gl.glGetProgramInfoLog(prog).decode()
print(f"Program link error: {log}")
gl.glDeleteShader(vs)
gl.glDeleteShader(fs)
return prog
def _load_textures(self):
"""Load BLP textures via blp_convert → PIL → GL texture."""
gl = self._gl
try:
from PIL import Image
except ImportError:
print("PIL not available, textures disabled")
return
cache_dir = Path(os.path.expanduser("~/.cache/m2_viewer"))
cache_dir.mkdir(parents=True, exist_ok=True)
for i, tex in enumerate(self.parser.textures):
if tex["type"] != 0 or not tex["filename"]:
continue
fname = tex["filename"].replace("\\", "/")
blp_path = self.blp_paths.get(fname) or self.blp_paths.get(fname.lower())
if not blp_path:
continue
# Convert BLP to PNG
cache_key = hashlib.md5(blp_path.encode()).hexdigest()
cached_png = cache_dir / f"{cache_key}.png"
if not cached_png.exists():
try:
# Copy BLP to temp dir for conversion (avoids read-only source dirs)
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
tmp_blp = Path(tmpdir) / Path(blp_path).name
shutil.copy2(blp_path, str(tmp_blp))
result = subprocess.run(
[self.blp_convert_path, "--to-png", str(tmp_blp)],
capture_output=True, text=True, timeout=10,
)
output_png = tmp_blp.with_suffix(".png")
if result.returncode != 0 or not output_png.exists():
print(f"blp_convert failed for {fname}: {result.stderr}")
continue
shutil.move(str(output_png), str(cached_png))
except Exception as e:
print(f"BLP convert failed for {fname}: {e}")
continue
try:
img = Image.open(cached_png)
img = img.transpose(Image.FLIP_TOP_BOTTOM)
if img.mode != "RGBA":
img = img.convert("RGBA")
img_data = np.array(img, dtype=np.uint8)
tex_id = gl.glGenTextures(1)
gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id)
gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, img.width, img.height,
0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, img_data)
gl.glGenerateMipmap(gl.GL_TEXTURE_2D)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR_MIPMAP_LINEAR)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_REPEAT)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_REPEAT)
self.gl_textures[i] = tex_id
except Exception as e:
print(f"Texture load failed for {fname}: {e}")
def _map_batch_textures(self):
"""Resolve batch → texture combo → texture lookup → GL texture mapping."""
for bi, batch in enumerate(self.parser.batches):
tci = batch.texture_combo_index
if tci < len(self.parser.texture_lookup):
tex_idx = self.parser.texture_lookup[tci]
if tex_idx in self.gl_textures:
self.batch_texture_map[bi] = self.gl_textures[tex_idx]
def update_vertices(self, skinned_positions: np.ndarray):
"""Upload new skinned vertex positions to VBO."""
gl = self._gl
if self.vao == 0 or len(skinned_positions) == 0:
return
p = self.parser
n_verts = len(skinned_positions)
# Rebuild interleaved VBO data with new positions
vbo_data = np.zeros((n_verts, 8), dtype=np.float32)
vbo_data[:, 0:3] = skinned_positions
vbo_data[:, 3:6] = p.normals if len(p.normals) == n_verts else np.zeros((n_verts, 3), dtype=np.float32)
vbo_data[:, 6:8] = p.uvs if len(p.uvs) == n_verts else np.zeros((n_verts, 2), dtype=np.float32)
gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo)
gl.glBufferSubData(gl.GL_ARRAY_BUFFER, 0, vbo_data.nbytes, vbo_data)
# Update wireframe VBO too
gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.wire_vbo)
gl.glBufferSubData(gl.GL_ARRAY_BUFFER, 0, skinned_positions.nbytes, skinned_positions)
def render(self, mvp: np.ndarray, model: np.ndarray):
gl = self._gl
if self.vao == 0 or self.n_indices == 0:
return
gl.glEnable(gl.GL_DEPTH_TEST)
gl.glDisable(gl.GL_CULL_FACE)
gl.glUseProgram(self.shader)
mvp_loc = gl.glGetUniformLocation(self.shader, "uMVP")
model_loc = gl.glGetUniformLocation(self.shader, "uModel")
tex_loc = gl.glGetUniformLocation(self.shader, "uTexture")
has_tex_loc = gl.glGetUniformLocation(self.shader, "uHasTexture")
light_loc = gl.glGetUniformLocation(self.shader, "uLightDir")
gl.glUniformMatrix4fv(mvp_loc, 1, gl.GL_TRUE, mvp)
gl.glUniformMatrix4fv(model_loc, 1, gl.GL_TRUE, model)
gl.glUniform1i(tex_loc, 0)
# Light direction (normalized)
light_dir = np.array([0.5, 0.3, 0.8], dtype=np.float32)
light_dir /= np.linalg.norm(light_dir)
gl.glUniform3fv(light_loc, 1, light_dir)
gl.glBindVertexArray(self.vao)
if self.parser.batches and self.parser.submeshes:
# Per-batch rendering
for bi, batch in enumerate(self.parser.batches):
si = batch.submesh_index
if si >= len(self.parser.submeshes):
continue
sm = self.parser.submeshes[si]
# Bind texture if available
gl_tex = self.batch_texture_map.get(bi)
if gl_tex:
gl.glActiveTexture(gl.GL_TEXTURE0)
gl.glBindTexture(gl.GL_TEXTURE_2D, gl_tex)
gl.glUniform1i(has_tex_loc, 1)
else:
gl.glUniform1i(has_tex_loc, 0)
# Draw this submesh's triangles
idx_start = sm.index_start
idx_count = sm.index_count
if idx_start + idx_count <= self.n_indices:
gl.glDrawElements(gl.GL_TRIANGLES, idx_count, gl.GL_UNSIGNED_SHORT,
gl.ctypes.c_void_p(idx_start * 2))
else:
# Fallback: draw all triangles with no texture
gl.glUniform1i(has_tex_loc, 0)
gl.glDrawElements(gl.GL_TRIANGLES, self.n_indices, gl.GL_UNSIGNED_SHORT,
gl.ctypes.c_void_p(0))
gl.glBindVertexArray(0)
# Wireframe overlay
if self.show_wireframe and self.wire_vao and self.n_wire_indices > 0:
gl.glUseProgram(self.wire_shader)
wire_mvp_loc = gl.glGetUniformLocation(self.wire_shader, "uMVP")
gl.glUniformMatrix4fv(wire_mvp_loc, 1, gl.GL_TRUE, mvp)
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE)
gl.glDisable(gl.GL_CULL_FACE)
gl.glBindVertexArray(self.wire_vao)
gl.glDrawElements(gl.GL_TRIANGLES, self.n_wire_indices, gl.GL_UNSIGNED_SHORT,
gl.ctypes.c_void_p(0))
gl.glBindVertexArray(0)
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL)
gl.glDisable(gl.GL_BLEND)
# ---------------------------------------------------------------------------
# M2 Viewer Window (Pygame main loop)
# ---------------------------------------------------------------------------
class M2ViewerWindow:
"""Pygame + OpenGL M2 model viewer window."""
def __init__(self, m2_path: str, blp_paths: dict[str, str], blp_convert: str):
self.m2_path = m2_path
self.blp_paths = blp_paths
self.blp_convert = blp_convert
self.parser: M2Parser | None = None
self.anim_system: AnimationSystem | None = None
self.renderer: M2Renderer | None = None
self.camera = OrbitCamera()
self.width = 1024
self.height = 768
self.running = True
self.fps_clock = None
self.font = None
self._dragging = False
self._panning = False
self._last_mouse = (0, 0)
def run(self):
"""Main entry point — parse, init GL, run loop."""
import pygame
from pygame.locals import (
DOUBLEBUF, OPENGL, RESIZABLE, QUIT, KEYDOWN, MOUSEBUTTONDOWN,
MOUSEBUTTONUP, MOUSEMOTION, VIDEORESIZE,
K_SPACE, K_LEFT, K_RIGHT, K_PLUS, K_MINUS, K_EQUALS, K_r, K_w,
K_ESCAPE,
)
# Parse M2
data = Path(self.m2_path).read_bytes()
if len(data) < 8 or data[:4] != b"MD20":
print(f"Not a valid M2 file: {self.m2_path}")
return
self.parser = M2Parser(data)
# Load skin file
m2_p = Path(self.m2_path)
skin_path = m2_p.with_name(m2_p.stem + "00.skin")
if skin_path.exists():
self.parser.parse_skin_data(skin_path.read_bytes())
elif self.parser.is_vanilla:
# Embedded skin at ofsViews
if self.parser.version <= 256:
# Read ofsViews from vanilla header
if len(data) > 108:
ofs_views = struct.unpack_from("<I", data, 100)[0]
if ofs_views > 0 and ofs_views < len(data):
self.parser.parse_skin_data(data[ofs_views:])
# Init animation
self.anim_system = AnimationSystem(self.parser)
if self.parser.animations:
self.anim_system.set_sequence(0)
# Auto-fit camera
if len(self.parser.positions) > 0:
mins = self.parser.positions.min(axis=0)
maxs = self.parser.positions.max(axis=0)
center = (mins + maxs) / 2.0
extent = np.linalg.norm(maxs - mins)
self.camera.target = center
self.camera.distance = max(extent * 1.2, 1.0)
# Init Pygame + OpenGL
pygame.init()
pygame.display.set_caption(f"M2 Viewer — {Path(self.m2_path).name}")
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK,
pygame.GL_CONTEXT_PROFILE_CORE)
pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE)
self.fps_clock = pygame.time.Clock()
self.font = pygame.font.SysFont("monospace", 14)
import OpenGL.GL as gl
# Init renderer
self.renderer = M2Renderer(self.parser, self.blp_paths, self.blp_convert)
self.renderer.init_gl()
gl.glClearColor(0.12, 0.12, 0.18, 1.0)
gl.glEnable(gl.GL_DEPTH_TEST)
# Main loop
while self.running:
dt = self.fps_clock.tick(60) / 1000.0
for event in pygame.event.get():
if event.type == QUIT:
self.running = False
elif event.type == VIDEORESIZE:
self.width, self.height = event.w, event.h
pygame.display.set_mode((self.width, self.height),
DOUBLEBUF | OPENGL | RESIZABLE)
elif event.type == KEYDOWN:
self._handle_key(event.key)
elif event.type == MOUSEBUTTONDOWN:
if event.button == 1:
self._dragging = True
self._last_mouse = event.pos
elif event.button == 3:
self._panning = True
self._last_mouse = event.pos
elif event.button == 4:
self.camera.zoom(1)
elif event.button == 5:
self.camera.zoom(-1)
elif event.type == MOUSEBUTTONUP:
if event.button == 1:
self._dragging = False
elif event.button == 3:
self._panning = False
elif event.type == MOUSEMOTION:
if self._dragging:
dx = event.pos[0] - self._last_mouse[0]
dy = event.pos[1] - self._last_mouse[1]
self.camera.orbit(dx, dy)
self._last_mouse = event.pos
elif self._panning:
dx = event.pos[0] - self._last_mouse[0]
dy = event.pos[1] - self._last_mouse[1]
self.camera.pan(-dx, dy)
self._last_mouse = event.pos
# Update animation + skinning
if self.anim_system:
self.anim_system.update(dt)
if (len(self.anim_system.bone_matrices) > 0
and len(self.parser.bone_lookup) > 0):
skinned = self.anim_system.skin_vertices(
self.parser.positions,
self.parser.bone_weights,
self.parser.bone_indices,
self.parser.bone_lookup,
)
self.renderer.update_vertices(skinned)
# Render
gl.glViewport(0, 0, self.width, self.height)
gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
aspect = self.width / max(self.height, 1)
proj = perspective(45.0, aspect, 0.01, 5000.0)
view = self.camera.get_view_matrix()
model = np.eye(4, dtype=np.float32)
mvp = proj @ view @ model
self.renderer.render(mvp, model)
# HUD overlay
self._draw_hud(pygame, gl)
pygame.display.flip()
pygame.quit()
def _handle_key(self, key):
import pygame
if key == pygame.K_ESCAPE:
self.running = False
elif key == pygame.K_SPACE:
if self.anim_system:
self.anim_system.playing = not self.anim_system.playing
elif key == pygame.K_RIGHT:
if self.anim_system and self.parser.animations:
idx = (self.anim_system.current_seq + 1) % len(self.parser.animations)
self.anim_system.set_sequence(idx)
elif key == pygame.K_LEFT:
if self.anim_system and self.parser.animations:
idx = (self.anim_system.current_seq - 1) % len(self.parser.animations)
self.anim_system.set_sequence(idx)
elif key in (pygame.K_PLUS, pygame.K_EQUALS, pygame.K_KP_PLUS):
if self.anim_system:
self.anim_system.speed = min(self.anim_system.speed + 0.25, 5.0)
elif key in (pygame.K_MINUS, pygame.K_KP_MINUS):
if self.anim_system:
self.anim_system.speed = max(self.anim_system.speed - 0.25, 0.0)
elif key == pygame.K_r:
if self.anim_system:
self.anim_system.time_ms = 0.0
self.anim_system.playing = False
self.anim_system.bone_matrices = np.empty(0)
elif key == pygame.K_w:
if self.renderer:
self.renderer.show_wireframe = not self.renderer.show_wireframe
def _draw_hud(self, pygame, gl):
"""Draw text overlay using Pygame font → texture approach."""
if not self.font:
return
lines = [Path(self.m2_path).name]
n_verts = len(self.parser.positions)
n_tris = len(self.parser.triangles) // 3
lines.append(f"{n_verts} verts, {n_tris} tris, {len(self.parser.textures)} tex")
if self.parser.animations and self.anim_system:
anim = self.parser.animations[self.anim_system.current_seq]
name = _ANIM_NAMES.get(anim.anim_id, f"Anim {anim.anim_id}")
state = "Playing" if self.anim_system.playing else "Paused"
lines.append(f"[{self.anim_system.current_seq + 1}/{len(self.parser.animations)}] "
f"{name} ({anim.duration}ms) - {state} x{self.anim_system.speed:.1f}")
else:
lines.append("No animations")
fps = self.fps_clock.get_fps() if self.fps_clock else 0
lines.append(f"FPS: {fps:.0f}")
lines.append("")
lines.append("LMB: orbit | RMB: pan | Scroll: zoom")
lines.append("Space: play/pause | Left/Right: anim | +/-: speed")
lines.append("W: wireframe | R: reset | Esc: quit")
# Render text to surface, then blit via orthographic projection
# Use a simple texture-based approach
line_height = 18
total_height = len(lines) * line_height + 8
surf_width = 450
surf = pygame.Surface((surf_width, total_height), pygame.SRCALPHA)
surf.fill((0, 0, 0, 160))
for i, line in enumerate(lines):
text_surf = self.font.render(line, True, (220, 220, 240))
surf.blit(text_surf, (6, 4 + i * line_height))
# Convert to OpenGL texture and draw
text_data = pygame.image.tostring(surf, "RGBA", True)
tex_id = gl.glGenTextures(1)
gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id)
gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, surf_width, total_height,
0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, text_data)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST)
# Draw fullscreen quad in ortho — use compatibility approach with glWindowPos + glDrawPixels
# Simpler: use a small shader-less blit via fixed function emulation
# Actually, let's just use the modern approach with a screen quad
self._blit_texture(gl, tex_id, 8, self.height - total_height - 8, surf_width, total_height)
gl.glDeleteTextures(1, [tex_id])
def _blit_texture(self, gl, tex_id, x, y, w, h):
"""Blit a texture to screen at (x,y) using a temporary screen-space quad."""
# Simple blit using glBlitFramebuffer alternative:
# Create a minimal screen-space shader + quad
if not hasattr(self, '_blit_shader'):
blit_vert = """
#version 330 core
layout(location=0) in vec2 aPos;
layout(location=1) in vec2 aUV;
out vec2 vUV;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
vUV = aUV;
}
"""
blit_frag = """
#version 330 core
in vec2 vUV;
uniform sampler2D uTex;
out vec4 FragColor;
void main() {
FragColor = texture(uTex, vUV);
}
"""
self._blit_shader = self.renderer._compile_program(blit_vert, blit_frag)
self._blit_vao = gl.glGenVertexArrays(1)
self._blit_vbo = gl.glGenBuffers(1)
# Convert pixel coords to NDC
x0 = 2.0 * x / self.width - 1.0
y0 = 2.0 * y / self.height - 1.0
x1 = 2.0 * (x + w) / self.width - 1.0
y1 = 2.0 * (y + h) / self.height - 1.0
quad = np.array([
x0, y0, 0.0, 0.0,
x1, y0, 1.0, 0.0,
x1, y1, 1.0, 1.0,
x0, y0, 0.0, 0.0,
x1, y1, 1.0, 1.0,
x0, y1, 0.0, 1.0,
], dtype=np.float32)
gl.glBindVertexArray(self._blit_vao)
gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._blit_vbo)
gl.glBufferData(gl.GL_ARRAY_BUFFER, quad.nbytes, quad, gl.GL_DYNAMIC_DRAW)
gl.glVertexAttribPointer(0, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(0))
gl.glEnableVertexAttribArray(0)
gl.glVertexAttribPointer(1, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(8))
gl.glEnableVertexAttribArray(1)
gl.glDisable(gl.GL_DEPTH_TEST)
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
gl.glUseProgram(self._blit_shader)
gl.glActiveTexture(gl.GL_TEXTURE0)
gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id)
gl.glUniform1i(gl.glGetUniformLocation(self._blit_shader, "uTex"), 0)
gl.glDrawArrays(gl.GL_TRIANGLES, 0, 6)
gl.glBindVertexArray(0)
gl.glEnable(gl.GL_DEPTH_TEST)
gl.glDisable(gl.GL_BLEND)
# ---------------------------------------------------------------------------
# WMO Parser
# ---------------------------------------------------------------------------
@dataclass
class WMOBatch:
start_index: int = 0
index_count: int = 0
material_id: int = 0
@dataclass
class WMOMaterial:
flags: int = 0
shader: int = 0
blend_mode: int = 0
texture1_ofs: int = 0
texture2_ofs: int = 0
texture3_ofs: int = 0
color1: int = 0
color2: int = 0
@dataclass
class WMOGroup:
positions: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32))
normals: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32))
uvs: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32))
indices: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.uint16))
batches: list = field(default_factory=list)
class WMOParser:
"""Parse WMO root + group files for rendering."""
def __init__(self):
self.textures: list[str] = []
self.texture_offset_map: dict[int, int] = {} # MOTX byte offset -> texture index
self.materials: list[WMOMaterial] = []
self.groups: list[WMOGroup] = []
self.n_groups_expected: int = 0
def parse_root(self, data: bytes):
"""Parse root WMO file for textures and materials."""
pos = 0
while pos + 8 <= len(data):
chunk_id = data[pos:pos + 4]
chunk_size = struct.unpack_from("<I", data, pos + 4)[0]
chunk_start = pos + 8
chunk_end = chunk_start + chunk_size
if chunk_end > len(data):
break
cid = chunk_id if chunk_id[:1] == b"M" else chunk_id[::-1]
if cid == b"MOHD" and chunk_size >= 16:
# nTextures at +0, nGroups at +4
self.n_groups_expected = struct.unpack_from("<I", data, chunk_start + 4)[0]
elif cid == b"MOTX":
# Null-terminated string block
off = 0
tex_idx = 0
while off < chunk_size:
end = data.find(b"\x00", chunk_start + off, chunk_end)
if end < 0:
break
s = data[chunk_start + off:end].decode("ascii", errors="replace")
if s:
self.texture_offset_map[off] = tex_idx
self.textures.append(s)
tex_idx += 1
off = end - chunk_start + 1
else:
off += 1
elif cid == b"MOMT":
n_mats = chunk_size // 64
for i in range(n_mats):
base = chunk_start + i * 64
fields = struct.unpack_from("<16I", data, base)
mat = WMOMaterial()
mat.flags = fields[0]
mat.shader = fields[1]
mat.blend_mode = fields[2]
mat.texture1_ofs = fields[3]
mat.color1 = fields[4]
mat.texture2_ofs = fields[6]
mat.color2 = fields[7]
mat.texture3_ofs = fields[9]
self.materials.append(mat)
pos = chunk_end
def parse_group(self, data: bytes) -> WMOGroup:
"""Parse a WMO group file for geometry."""
group = WMOGroup()
pos = 0
# Scan for MOGP chunk which wraps all sub-chunks
mogp_start = -1
mogp_end = len(data)
while pos + 8 <= len(data):
chunk_id = data[pos:pos + 4]
chunk_size = struct.unpack_from("<I", data, pos + 4)[0]
cid = chunk_id if chunk_id[:1] == b"M" else chunk_id[::-1]
if cid == b"MOGP":
mogp_start = pos + 8 + 68 # Skip MOGP header (68 bytes)
mogp_end = pos + 8 + chunk_size
break
pos += 8 + chunk_size
# Parse sub-chunks inside MOGP (or scan whole file if no MOGP found)
scan_start = mogp_start if mogp_start >= 0 else 0
pos = scan_start
while pos + 8 <= mogp_end:
chunk_id = data[pos:pos + 4]
chunk_size = struct.unpack_from("<I", data, pos + 4)[0]
chunk_start = pos + 8
chunk_end = chunk_start + chunk_size
if chunk_end > mogp_end:
break
cid = chunk_id if chunk_id[:1] == b"M" else chunk_id[::-1]
if cid == b"MOVT":
n = chunk_size // 12
group.positions = np.zeros((n, 3), dtype=np.float32)
for i in range(n):
group.positions[i] = struct.unpack_from("<3f", data, chunk_start + i * 12)
elif cid == b"MOVI":
n = chunk_size // 2
group.indices = np.frombuffer(data, dtype=np.uint16,
count=n, offset=chunk_start).copy()
elif cid == b"MONR":
n = chunk_size // 12
group.normals = np.zeros((n, 3), dtype=np.float32)
for i in range(n):
group.normals[i] = struct.unpack_from("<3f", data, chunk_start + i * 12)
elif cid == b"MOTV":
n = chunk_size // 8
group.uvs = np.zeros((n, 2), dtype=np.float32)
for i in range(n):
group.uvs[i] = struct.unpack_from("<2f", data, chunk_start + i * 8)
elif cid == b"MOBA":
n = chunk_size // 24
for i in range(n):
base = chunk_start + i * 24
batch = WMOBatch()
batch.start_index = struct.unpack_from("<I", data, base + 12)[0]
batch.index_count = struct.unpack_from("<H", data, base + 16)[0]
# skip startVertex(2) + lastVertex(2) + flags(1)
batch.material_id = struct.unpack_from("<B", data, base + 23)[0]
group.batches.append(batch)
pos = chunk_end
return group
def get_texture_name(self, motx_offset: int) -> str:
"""Resolve a MOTX byte offset to a texture filename."""
idx = self.texture_offset_map.get(motx_offset)
if idx is not None and idx < len(self.textures):
return self.textures[idx]
return ""
# ---------------------------------------------------------------------------
# WMO Renderer
# ---------------------------------------------------------------------------
class WMORenderer:
"""OpenGL 3.3 renderer for WMO models."""
def __init__(self, parser: WMOParser, blp_paths: dict[str, str], blp_convert: str):
self.parser = parser
self.blp_paths = blp_paths
self.blp_convert_path = blp_convert
self.show_wireframe = False
# Per-group GL state
self._group_vaos: list[int] = []
self._group_vbos: list[int] = []
self._group_ebos: list[int] = []
self._group_n_indices: list[int] = []
self._group_batches: list[list[WMOBatch]] = []
self.shader = 0
self.wire_shader = 0
self._gl = None
# material_id -> GL texture id
self._mat_textures: dict[int, int] = {}
def init_gl(self):
import OpenGL.GL as gl
self._gl = gl
self.shader = self._compile_program(VERT_SHADER, FRAG_SHADER)
self.wire_shader = self._compile_program(WIRE_VERT, WIRE_FRAG)
self._load_textures()
for group in self.parser.groups:
self._upload_group(group)
def _upload_group(self, group: WMOGroup):
gl = self._gl
n_verts = len(group.positions)
if n_verts == 0:
self._group_vaos.append(0)
self._group_vbos.append(0)
self._group_ebos.append(0)
self._group_n_indices.append(0)
self._group_batches.append([])
return
# Interleaved: pos(12) + normal(12) + uv(8) = 32 bytes
vbo_data = np.zeros((n_verts, 8), dtype=np.float32)
vbo_data[:, 0:3] = group.positions
if len(group.normals) == n_verts:
vbo_data[:, 3:6] = group.normals
if len(group.uvs) == n_verts:
vbo_data[:, 6:8] = group.uvs
vao = gl.glGenVertexArrays(1)
vbo = gl.glGenBuffers(1)
ebo = gl.glGenBuffers(1)
gl.glBindVertexArray(vao)
gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo)
gl.glBufferData(gl.GL_ARRAY_BUFFER, vbo_data.nbytes, vbo_data, gl.GL_STATIC_DRAW)
n_idx = 0
if len(group.indices) > 0:
idx_data = group.indices.astype(np.uint16)
n_idx = len(idx_data)
gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, ebo)
gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW)
stride = 32
gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(0))
gl.glEnableVertexAttribArray(0)
gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(12))
gl.glEnableVertexAttribArray(1)
gl.glVertexAttribPointer(2, 2, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(24))
gl.glEnableVertexAttribArray(2)
gl.glBindVertexArray(0)
self._group_vaos.append(vao)
self._group_vbos.append(vbo)
self._group_ebos.append(ebo)
self._group_n_indices.append(n_idx)
self._group_batches.append(group.batches)
def _load_textures(self):
gl = self._gl
try:
from PIL import Image
except ImportError:
return
cache_dir = Path(os.path.expanduser("~/.cache/m2_viewer"))
cache_dir.mkdir(parents=True, exist_ok=True)
loaded: dict[str, int] = {} # filename -> GL tex id
for mat_idx, mat in enumerate(self.parser.materials):
tex_name = self.parser.get_texture_name(mat.texture1_ofs)
if not tex_name:
continue
if tex_name in loaded:
self._mat_textures[mat_idx] = loaded[tex_name]
continue
norm = tex_name.replace("\\", "/")
blp_path = self.blp_paths.get(norm) or self.blp_paths.get(norm.lower())
if not blp_path:
continue
cache_key = hashlib.md5(blp_path.encode()).hexdigest()
cached_png = cache_dir / f"{cache_key}.png"
if not cached_png.exists():
try:
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
tmp_blp = Path(tmpdir) / Path(blp_path).name
shutil.copy2(blp_path, str(tmp_blp))
result = subprocess.run(
[self.blp_convert_path, "--to-png", str(tmp_blp)],
capture_output=True, text=True, timeout=10,
)
output_png = tmp_blp.with_suffix(".png")
if result.returncode != 0 or not output_png.exists():
continue
shutil.move(str(output_png), str(cached_png))
except Exception:
continue
try:
img = Image.open(cached_png)
img = img.transpose(Image.FLIP_TOP_BOTTOM)
if img.mode != "RGBA":
img = img.convert("RGBA")
img_data = np.array(img, dtype=np.uint8)
tex_id = gl.glGenTextures(1)
gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id)
gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, img.width, img.height,
0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, img_data)
gl.glGenerateMipmap(gl.GL_TEXTURE_2D)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR_MIPMAP_LINEAR)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_REPEAT)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_REPEAT)
loaded[tex_name] = tex_id
self._mat_textures[mat_idx] = tex_id
except Exception:
continue
def _compile_program(self, vert_src: str, frag_src: str) -> int:
gl = self._gl
vs = gl.glCreateShader(gl.GL_VERTEX_SHADER)
gl.glShaderSource(vs, vert_src)
gl.glCompileShader(vs)
fs = gl.glCreateShader(gl.GL_FRAGMENT_SHADER)
gl.glShaderSource(fs, frag_src)
gl.glCompileShader(fs)
prog = gl.glCreateProgram()
gl.glAttachShader(prog, vs)
gl.glAttachShader(prog, fs)
gl.glLinkProgram(prog)
gl.glDeleteShader(vs)
gl.glDeleteShader(fs)
return prog
def render(self, mvp: np.ndarray, model: np.ndarray):
gl = self._gl
gl.glEnable(gl.GL_DEPTH_TEST)
gl.glDisable(gl.GL_CULL_FACE)
gl.glUseProgram(self.shader)
mvp_loc = gl.glGetUniformLocation(self.shader, "uMVP")
model_loc = gl.glGetUniformLocation(self.shader, "uModel")
tex_loc = gl.glGetUniformLocation(self.shader, "uTexture")
has_tex_loc = gl.glGetUniformLocation(self.shader, "uHasTexture")
light_loc = gl.glGetUniformLocation(self.shader, "uLightDir")
gl.glUniformMatrix4fv(mvp_loc, 1, gl.GL_TRUE, mvp)
gl.glUniformMatrix4fv(model_loc, 1, gl.GL_TRUE, model)
gl.glUniform1i(tex_loc, 0)
light_dir = np.array([0.5, 0.3, 0.8], dtype=np.float32)
light_dir /= np.linalg.norm(light_dir)
gl.glUniform3fv(light_loc, 1, light_dir)
for gi in range(len(self._group_vaos)):
vao = self._group_vaos[gi]
n_idx = self._group_n_indices[gi]
batches = self._group_batches[gi]
if vao == 0 or n_idx == 0:
continue
gl.glBindVertexArray(vao)
if batches:
for batch in batches:
gl_tex = self._mat_textures.get(batch.material_id)
if gl_tex:
gl.glActiveTexture(gl.GL_TEXTURE0)
gl.glBindTexture(gl.GL_TEXTURE_2D, gl_tex)
gl.glUniform1i(has_tex_loc, 1)
else:
gl.glUniform1i(has_tex_loc, 0)
si = batch.start_index
ic = batch.index_count
if si + ic <= n_idx:
gl.glDrawElements(gl.GL_TRIANGLES, ic, gl.GL_UNSIGNED_SHORT,
gl.ctypes.c_void_p(si * 2))
else:
gl.glUniform1i(has_tex_loc, 0)
gl.glDrawElements(gl.GL_TRIANGLES, n_idx, gl.GL_UNSIGNED_SHORT,
gl.ctypes.c_void_p(0))
gl.glBindVertexArray(0)
# Wireframe overlay
if self.show_wireframe:
gl.glUseProgram(self.wire_shader)
wire_mvp_loc = gl.glGetUniformLocation(self.wire_shader, "uMVP")
gl.glUniformMatrix4fv(wire_mvp_loc, 1, gl.GL_TRUE, mvp)
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE)
for gi in range(len(self._group_vaos)):
vao = self._group_vaos[gi]
n_idx = self._group_n_indices[gi]
if vao == 0 or n_idx == 0:
continue
gl.glBindVertexArray(vao)
gl.glDrawElements(gl.GL_TRIANGLES, n_idx, gl.GL_UNSIGNED_SHORT,
gl.ctypes.c_void_p(0))
gl.glBindVertexArray(0)
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL)
gl.glDisable(gl.GL_BLEND)
# ---------------------------------------------------------------------------
# WMO Viewer Window
# ---------------------------------------------------------------------------
class WMOViewerWindow:
"""Pygame + OpenGL WMO model viewer window."""
def __init__(self, wmo_root_path: str, group_paths: list[str],
blp_paths: dict[str, str], blp_convert: str):
self.wmo_root_path = wmo_root_path
self.group_paths = group_paths
self.blp_paths = blp_paths
self.blp_convert = blp_convert
self.parser: WMOParser | None = None
self.renderer: WMORenderer | None = None
self.camera = OrbitCamera()
self.width = 1024
self.height = 768
self.running = True
self.fps_clock = None
self.font = None
self._dragging = False
self._panning = False
self._last_mouse = (0, 0)
def run(self):
import pygame
from pygame.locals import (
DOUBLEBUF, OPENGL, RESIZABLE, QUIT, KEYDOWN, MOUSEBUTTONDOWN,
MOUSEBUTTONUP, MOUSEMOTION, VIDEORESIZE,
)
# Parse WMO
self.parser = WMOParser()
if self.wmo_root_path and Path(self.wmo_root_path).exists():
self.parser.parse_root(Path(self.wmo_root_path).read_bytes())
total_verts = 0
total_tris = 0
for gp in self.group_paths:
if Path(gp).exists():
group = self.parser.parse_group(Path(gp).read_bytes())
self.parser.groups.append(group)
total_verts += len(group.positions)
total_tris += len(group.indices) // 3
if total_verts == 0:
print("No geometry found in WMO groups")
return
# Auto-fit camera
all_pos = np.vstack([g.positions for g in self.parser.groups if len(g.positions) > 0])
mins = all_pos.min(axis=0)
maxs = all_pos.max(axis=0)
center = (mins + maxs) / 2.0
extent = np.linalg.norm(maxs - mins)
self.camera.target = center
self.camera.distance = max(extent * 1.2, 1.0)
# Init Pygame
pygame.init()
name = Path(self.wmo_root_path or self.group_paths[0]).stem
pygame.display.set_caption(f"WMO Viewer — {name}")
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK,
pygame.GL_CONTEXT_PROFILE_CORE)
pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE)
self.fps_clock = pygame.time.Clock()
self.font = pygame.font.SysFont("monospace", 14)
import OpenGL.GL as gl
self.renderer = WMORenderer(self.parser, self.blp_paths, self.blp_convert)
self.renderer.init_gl()
gl.glClearColor(0.12, 0.12, 0.18, 1.0)
while self.running:
self.fps_clock.tick(60)
for event in pygame.event.get():
if event.type == QUIT:
self.running = False
elif event.type == VIDEORESIZE:
self.width, self.height = event.w, event.h
pygame.display.set_mode((self.width, self.height),
DOUBLEBUF | OPENGL | RESIZABLE)
elif event.type == KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.running = False
elif event.key == pygame.K_w:
self.renderer.show_wireframe = not self.renderer.show_wireframe
elif event.type == MOUSEBUTTONDOWN:
if event.button == 1:
self._dragging = True
self._last_mouse = event.pos
elif event.button == 3:
self._panning = True
self._last_mouse = event.pos
elif event.button == 4:
self.camera.zoom(1)
elif event.button == 5:
self.camera.zoom(-1)
elif event.type == MOUSEBUTTONUP:
if event.button == 1:
self._dragging = False
elif event.button == 3:
self._panning = False
elif event.type == MOUSEMOTION:
if self._dragging:
dx = event.pos[0] - self._last_mouse[0]
dy = event.pos[1] - self._last_mouse[1]
self.camera.orbit(dx, dy)
self._last_mouse = event.pos
elif self._panning:
dx = event.pos[0] - self._last_mouse[0]
dy = event.pos[1] - self._last_mouse[1]
self.camera.pan(-dx, dy)
self._last_mouse = event.pos
gl.glViewport(0, 0, self.width, self.height)
gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
aspect = self.width / max(self.height, 1)
proj = perspective(45.0, aspect, 0.1, 10000.0)
view = self.camera.get_view_matrix()
model_mat = np.eye(4, dtype=np.float32)
mvp = proj @ view @ model_mat
self.renderer.render(mvp, model_mat)
# HUD
self._draw_hud(pygame, gl, total_verts, total_tris)
pygame.display.flip()
pygame.quit()
def _draw_hud(self, pygame, gl, total_verts, total_tris):
if not self.font:
return
name = Path(self.wmo_root_path or self.group_paths[0]).name
lines = [
name,
f"{len(self.parser.groups)} groups, {total_verts} verts, {total_tris} tris",
f"{len(self.parser.materials)} materials, {len(self.parser.textures)} textures",
f"FPS: {self.fps_clock.get_fps():.0f}",
"",
"LMB: orbit | RMB: pan | Scroll: zoom",
"W: wireframe | Esc: quit",
]
line_height = 18
total_height = len(lines) * line_height + 8
surf_width = 420
surf = pygame.Surface((surf_width, total_height), pygame.SRCALPHA)
surf.fill((0, 0, 0, 160))
for i, line in enumerate(lines):
text_surf = self.font.render(line, True, (220, 220, 240))
surf.blit(text_surf, (6, 4 + i * line_height))
text_data = pygame.image.tostring(surf, "RGBA", True)
tex_id = gl.glGenTextures(1)
gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id)
gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, surf_width, total_height,
0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, text_data)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST)
# Blit using the same approach as M2ViewerWindow
if not hasattr(self, '_blit_shader'):
blit_vert = """
#version 330 core
layout(location=0) in vec2 aPos;
layout(location=1) in vec2 aUV;
out vec2 vUV;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); vUV = aUV; }
"""
blit_frag = """
#version 330 core
in vec2 vUV;
uniform sampler2D uTex;
out vec4 FragColor;
void main() { FragColor = texture(uTex, vUV); }
"""
self._blit_shader = self.renderer._compile_program(blit_vert, blit_frag)
self._blit_vao = gl.glGenVertexArrays(1)
self._blit_vbo = gl.glGenBuffers(1)
x, y, w, h = 8, self.height - total_height - 8, surf_width, total_height
x0 = 2.0 * x / self.width - 1.0
y0 = 2.0 * y / self.height - 1.0
x1 = 2.0 * (x + w) / self.width - 1.0
y1 = 2.0 * (y + h) / self.height - 1.0
quad = np.array([
x0, y0, 0, 0, x1, y0, 1, 0, x1, y1, 1, 1,
x0, y0, 0, 0, x1, y1, 1, 1, x0, y1, 0, 1,
], dtype=np.float32)
gl.glBindVertexArray(self._blit_vao)
gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._blit_vbo)
gl.glBufferData(gl.GL_ARRAY_BUFFER, quad.nbytes, quad, gl.GL_DYNAMIC_DRAW)
gl.glVertexAttribPointer(0, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(0))
gl.glEnableVertexAttribArray(0)
gl.glVertexAttribPointer(1, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(8))
gl.glEnableVertexAttribArray(1)
gl.glDisable(gl.GL_DEPTH_TEST)
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
gl.glUseProgram(self._blit_shader)
gl.glUniform1i(gl.glGetUniformLocation(self._blit_shader, "uTex"), 0)
gl.glDrawArrays(gl.GL_TRIANGLES, 0, 6)
gl.glBindVertexArray(0)
gl.glEnable(gl.GL_DEPTH_TEST)
gl.glDisable(gl.GL_BLEND)
gl.glDeleteTextures(1, [tex_id])
# ---------------------------------------------------------------------------
# Launch entry points (multiprocessing-safe)
# ---------------------------------------------------------------------------
def _viewer_main(m2_path: str, blp_paths: dict[str, str], blp_convert: str):
"""Entry point for M2 viewer subprocess."""
viewer = M2ViewerWindow(m2_path, blp_paths, blp_convert)
viewer.run()
def _wmo_viewer_main(wmo_root: str, group_paths: list[str],
blp_paths: dict[str, str], blp_convert: str):
"""Entry point for WMO viewer subprocess."""
viewer = WMOViewerWindow(wmo_root, group_paths, blp_paths, blp_convert)
viewer.run()
def launch_m2_viewer(m2_path: str, blp_paths: dict[str, str], blp_convert: str):
"""Launch M2 viewer in a separate process to avoid Tkinter/Pygame conflicts."""
p = multiprocessing.Process(target=_viewer_main, args=(m2_path, blp_paths, blp_convert),
daemon=True)
p.start()
return p
def launch_wmo_viewer(wmo_root: str, group_paths: list[str],
blp_paths: dict[str, str], blp_convert: str):
"""Launch WMO viewer in a separate process."""
p = multiprocessing.Process(target=_wmo_viewer_main,
args=(wmo_root, group_paths, blp_paths, blp_convert),
daemon=True)
p.start()
return p
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python m2_viewer.py <m2_or_wmo_file> [blp_convert_path]")
sys.exit(1)
file_path = sys.argv[1]
blp_conv = sys.argv[2] if len(sys.argv) > 2 else ""
if file_path.lower().endswith(".wmo"):
# Detect root vs group and find all group files
p = Path(file_path)
name = p.name.lower()
is_group = len(name) > 8 and name[-8:-4].isdigit() and name[-9] == "_"
if is_group:
# Derive root from group
stem = p.stem
root_stem = stem.rsplit("_", 1)[0]
root_path = p.parent / f"{root_stem}.wmo"
groups = sorted(p.parent.glob(f"{root_stem}_*.wmo"))
else:
root_path = p
stem = p.stem
groups = sorted(p.parent.glob(f"{stem}_*.wmo"))
root_str = str(root_path) if root_path.exists() else ""
group_strs = [str(g) for g in groups]
if not group_strs and is_group:
group_strs = [file_path]
viewer = WMOViewerWindow(root_str, group_strs, {}, blp_conv)
viewer.run()
else:
viewer = M2ViewerWindow(file_path, {}, blp_conv)
viewer.run()