ayzeksalimli's picture
Push project (code, README, Docker/compose, models) — no .github/workflows
3972fea verified
Raw
History Blame Contribute Delete
6.12 kB
"""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)
# Clockwise on screen means the face turns away from the camera.
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)
# Sub-pixel triangles cannot show anything the neighbours miss.
& ((hi[:, 0] - lo[:, 0]) >= 0.7) & ((hi[:, 1] - lo[:, 1]) >= 0.7)
)
keep = np.flatnonzero(visible)
if keep.size == 0:
continue
# Sort first, then shade, so colours already come out in draw order.
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)
# OpenCV wants plain ints; one tolist beats per-triangle conversion.
return [tuple(c) for c in lit.tolist()]