File size: 6,121 Bytes
3972fea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """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()]
|