| """Triangle rasterizer for skinned character meshes. |
| |
| Every per-triangle quantity — projection, culling, lighting, colour — is |
| computed for the whole mesh in one batched numpy pass, leaving the inner loop |
| with nothing but a single OpenCV polygon fill per triangle. Shading a triangle |
| pixel by pixel in Python instead costs about thirty times as much, which is the |
| difference between a few frames per second and a usable one. |
| |
| Depth is resolved by drawing back to front rather than with a z-buffer: for a |
| character mesh the sort handles the cases that matter, such as an arm crossing |
| the chest, and it needs no per-pixel work. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import cv2 |
| import numpy as np |
|
|
| _LIGHT = np.array([0.4, -0.6, -0.9]) |
| _LIGHT /= np.linalg.norm(_LIGHT) |
| _FILL = np.array([-0.5, 0.2, -0.8]) |
| _FILL /= np.linalg.norm(_FILL) |
| _AMBIENT = 0.30 |
| _FILL_GAIN = 0.22 |
| _RIM_GAIN = 0.40 |
| _SPEC_GAIN = 0.28 |
| _SPEC_POWER = 24.0 |
| _HALF = _LIGHT + np.array([0.0, 0.0, 1.0]) |
| _HALF /= np.linalg.norm(_HALF) |
|
|
|
|
| def _rotation(yaw: float, pitch: float, roll: float = 0.0) -> np.ndarray: |
| """Build a yaw/pitch/roll rotation matrix.""" |
| cy, sy = np.cos(yaw), np.sin(yaw) |
| cp, sp = np.cos(pitch), np.sin(pitch) |
| cr, sr = np.cos(roll), np.sin(roll) |
| ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]]) |
| rx = np.array([[1, 0, 0], [0, cp, -sp], [0, sp, cp]]) |
| rz = np.array([[cr, -sr, 0], [sr, cr, 0], [0, 0, 1]]) |
| return rz @ ry @ rx |
|
|
|
|
| class Part: |
| """One skinned, drawable mesh piece in world space.""" |
|
|
| def __init__( |
| self, |
| positions: np.ndarray, |
| normals: np.ndarray, |
| triangles: np.ndarray, |
| *, |
| color: tuple[int, int, int] = (185, 180, 175), |
| vertex_colors: np.ndarray | None = None, |
| uv: np.ndarray | None = None, |
| texture: np.ndarray | None = None, |
| ) -> None: |
| self.positions = positions |
| self.normals = normals |
| self.triangles = triangles |
| self.color = color |
| self.vertex_colors = vertex_colors |
| self.uv = uv |
| self.texture = texture |
|
|
|
|
| class MeshRenderer: |
| """Painter's-algorithm rasterizer with batched shading.""" |
|
|
| def __init__(self, width: int, height: int) -> None: |
| self.width = width |
| self.height = height |
|
|
| def render( |
| self, |
| background: np.ndarray, |
| parts: list[Part], |
| *, |
| origin: tuple[float, float], |
| focal: float, |
| distance: float, |
| yaw: float, |
| pitch: float, |
| roll: float = 0.0, |
| ) -> np.ndarray: |
| """Draw skinned parts over the background.""" |
| out = background if background.flags.writeable else background.copy() |
| h, w = out.shape[:2] |
| rot = _rotation(yaw, pitch, roll) |
| cx, cy = origin |
|
|
| for part in parts: |
| tris = part.triangles |
| if part.positions.shape[0] == 0 or tris.shape[0] == 0: |
| continue |
|
|
| cam = part.positions @ rot.T |
| z = cam[:, 2] + distance |
| safe_z = np.maximum(z, 1e-3) |
| screen = np.empty((len(cam), 2)) |
| screen[:, 0] = focal * cam[:, 0] / safe_z + cx |
| screen[:, 1] = -focal * cam[:, 1] / safe_z + cy |
|
|
| pts = screen[tris] |
| depth = z[tris].mean(axis=1) |
| |
| winding = ((pts[:, 1, 0] - pts[:, 0, 0]) * (pts[:, 2, 1] - pts[:, 0, 1]) |
| - (pts[:, 2, 0] - pts[:, 0, 0]) * (pts[:, 1, 1] - pts[:, 0, 1])) |
| lo = pts.min(axis=1) |
| hi = pts.max(axis=1) |
| visible = ( |
| (winding < 0) & (depth > 0) & np.isfinite(depth) |
| & (hi[:, 0] >= 0) & (lo[:, 0] < w) & (hi[:, 1] >= 0) & (lo[:, 1] < h) |
| |
| & ((hi[:, 0] - lo[:, 0]) >= 0.7) & ((hi[:, 1] - lo[:, 1]) >= 0.7) |
| ) |
| keep = np.flatnonzero(visible) |
| if keep.size == 0: |
| continue |
|
|
| |
| order = keep[np.argsort(-depth[keep])] |
| poly = np.round(screen[tris[order]]).astype(np.int32) |
| palette = self._colors(part, tris[order], cam, rot) |
|
|
| for i in range(len(order)): |
| cv2.fillConvexPoly(out, poly[i], palette[i], cv2.LINE_8) |
| return out |
|
|
| @staticmethod |
| def _colors(part: Part, tris: np.ndarray, cam: np.ndarray, |
| rot: np.ndarray) -> list[tuple[int, int, int]]: |
| """Flat colour per triangle: unlit base times a light factor.""" |
| if part.normals.size: |
| n = (part.normals @ rot.T)[tris].mean(axis=1) |
| else: |
| v = cam[tris] |
| n = np.cross(v[:, 1] - v[:, 0], v[:, 2] - v[:, 0]) |
| n_len = np.linalg.norm(n, axis=1, keepdims=True) |
| n = n / np.where(n_len > 1e-9, n_len, 1.0) |
| n = np.where(n[:, 2:3] > 0, -n, n) |
|
|
| shade = (_AMBIENT |
| + (1.0 - _AMBIENT) * np.maximum(-(n @ _LIGHT), 0.0) |
| + _FILL_GAIN * np.maximum(-(n @ _FILL), 0.0) |
| + _RIM_GAIN * np.maximum(1.0 - np.abs(n[:, 2]), 0.0) ** 2.2 |
| + _SPEC_GAIN * np.maximum(-(n @ _HALF), 0.0) ** _SPEC_POWER) |
|
|
| if part.texture is not None and part.uv is not None: |
| tex = part.texture |
| th, tw = tex.shape[:2] |
| uv = part.uv[tris].mean(axis=1) |
| u = np.clip(np.mod(uv[:, 0], 1.0) * (tw - 1), 0, tw - 1).astype(np.int32) |
| v = np.clip(np.mod(uv[:, 1], 1.0) * (th - 1), 0, th - 1).astype(np.int32) |
| base = tex[v, u].astype(np.float64) |
| elif part.vertex_colors is not None: |
| base = part.vertex_colors[tris].mean(axis=1) |
| else: |
| base = np.broadcast_to(np.asarray(part.color, dtype=np.float64), (len(tris), 3)) |
|
|
| lit = np.clip(base * shade[:, None], 0, 255).astype(np.uint8) |
| |
| return [tuple(c) for c in lit.tolist()] |
|
|