kinescript 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kinescript/__init__.py +6 -0
- kinescript/cli.py +167 -0
- kinescript/color.py +89 -0
- kinescript/components.py +272 -0
- kinescript/docs.py +345 -0
- kinescript/ease.py +143 -0
- kinescript/fonts/IBMPlexMono-Bold.ttf +0 -0
- kinescript/fonts/IBMPlexMono-Medium.ttf +0 -0
- kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript/fonts/IBMPlexMono-Regular.ttf +0 -0
- kinescript/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Bold.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-Medium.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- kinescript/fonts/SpaceGrotesk-Regular.ttf +0 -0
- kinescript/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
- kinescript/mcp.py +148 -0
- kinescript/media.py +220 -0
- kinescript/mesh.py +390 -0
- kinescript/models.py +339 -0
- kinescript/output.py +370 -0
- kinescript/presets.py +170 -0
- kinescript/props.py +185 -0
- kinescript/render.py +1204 -0
- kinescript/render3d.py +393 -0
- kinescript/scene.py +362 -0
- kinescript/schema.py +701 -0
- kinescript/shaders.py +79 -0
- kinescript/space.py +81 -0
- kinescript/svgpath.py +80 -0
- kinescript/timing.py +169 -0
- kinescript/verify.py +261 -0
- kinescript-1.1.0.dist-info/METADATA +192 -0
- kinescript-1.1.0.dist-info/RECORD +40 -0
- kinescript-1.1.0.dist-info/WHEEL +5 -0
- kinescript-1.1.0.dist-info/entry_points.txt +2 -0
- kinescript-1.1.0.dist-info/licenses/LICENSE +25 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/IBMPlexMono-OFL.txt +93 -0
- kinescript-1.1.0.dist-info/licenses/kinescript/fonts/SpaceGrotesk-OFL.txt +93 -0
- kinescript-1.1.0.dist-info/top_level.txt +1 -0
kinescript/render3d.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Model layers, drawn each frame by numpy and one drawVertices call: transform, drop triangles that
|
|
2
|
+
face away or cross the camera plane, sort the rest far to near (painter's algorithm), and shade per
|
|
3
|
+
pixel in SkSL. The view-space normal rides in each vertex's texture coordinate with the material
|
|
4
|
+
index packed into it, so every part of a model draws in one call and hides the others correctly."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import skia
|
|
11
|
+
|
|
12
|
+
from . import color as C
|
|
13
|
+
from . import mesh, models, space, svgpath
|
|
14
|
+
|
|
15
|
+
MATERIAL = ("fill", "shine", "metal", "rim", "bands")
|
|
16
|
+
MAX_MATERIALS = 8
|
|
17
|
+
# Lights are fixed to the camera, like a studio key light on the camera rig: turning a model
|
|
18
|
+
# changes its shading, orbiting the camera around it does not.
|
|
19
|
+
LIGHTS = {"studio": {"key": 0.85, "fill": 0.25, "ambient": 0.22, "rim": 0.35, "from": "topleft"},
|
|
20
|
+
"soft": {"key": 0.55, "fill": 0.4, "ambient": 0.38, "rim": 0.2, "from": "top"},
|
|
21
|
+
"dramatic": {"key": 1.05, "fill": 0.06, "ambient": 0.06, "rim": 0.75, "from": "left"},
|
|
22
|
+
"flat": {"key": 0.0, "fill": 0.0, "ambient": 1.0, "rim": 0.0, "from": "front"}}
|
|
23
|
+
FROM = {"topleft": (-0.5, -0.6, 0.62), "top": (0.0, -0.75, 0.66), "topright": (0.5, -0.6, 0.62),
|
|
24
|
+
"left": (-0.85, -0.25, 0.46), "right": (0.85, -0.25, 0.46), "front": (0.0, 0.0, 1.0),
|
|
25
|
+
"bottomleft": (-0.5, 0.6, 0.62), "bottom": (0.0, 0.75, 0.66), "bottomright": (0.5, 0.6, 0.62)}
|
|
26
|
+
_SKSL = """
|
|
27
|
+
DECLARE
|
|
28
|
+
uniform half3 keyDir;
|
|
29
|
+
uniform half3 fillDir;
|
|
30
|
+
uniform half3 keyCol;
|
|
31
|
+
uniform half4 amt;
|
|
32
|
+
half4 main(float2 uv) {
|
|
33
|
+
float m = floor(uv.x * 0.5);
|
|
34
|
+
half4 b = base0;
|
|
35
|
+
half4 p = surf0;
|
|
36
|
+
LOOKUP
|
|
37
|
+
half2 n2 = half2((uv.x - 2.0 * m - 0.5) / 0.45, uv.y);
|
|
38
|
+
half3 n = normalize(half3(n2, sqrt(max(0.0, 1.0 - dot(n2, n2)))));
|
|
39
|
+
half d = max(dot(n, keyDir), 0.0);
|
|
40
|
+
half f = max(dot(n, fillDir), 0.0);
|
|
41
|
+
half s = pow(max(dot(n, normalize(keyDir + half3(0.0, 0.0, 1.0))), 0.0), p.y) * p.x;
|
|
42
|
+
if (b.a > 1.5) {
|
|
43
|
+
d = ceil(d * b.a - 0.25) / b.a;
|
|
44
|
+
f = ceil(f * b.a - 0.25) / b.a;
|
|
45
|
+
s = step(0.45, s) * p.x;
|
|
46
|
+
}
|
|
47
|
+
half3 diffuse = b.rgb * (amt.z + amt.x * d * keyCol + amt.y * f * half3(0.85, 0.9, 1.0)) * (1.0 - 0.75 * p.z);
|
|
48
|
+
half3 spec = mix(half3(1.0), b.rgb, p.z) * s * amt.x * keyCol;
|
|
49
|
+
half rim = pow(1.0 - n.z, 3.0) * p.w * amt.w;
|
|
50
|
+
return half4(saturate(diffuse + spec + rim * keyCol), 1.0);
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
_EFFECTS: dict[int, skia.RuntimeEffect] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _effect(n: int) -> skia.RuntimeEffect:
|
|
57
|
+
"""The shading effect for n materials, one pair of uniforms each (base colour + bands, then
|
|
58
|
+
specular amount, exponent, metal and rim)."""
|
|
59
|
+
if n not in _EFFECTS:
|
|
60
|
+
decl = "\n".join(f"uniform half4 base{i};\nuniform half4 surf{i};" for i in range(n))
|
|
61
|
+
pick = " ".join(f"if (m == {i}.0) {{ b = base{i}; p = surf{i}; }}" for i in range(1, n))
|
|
62
|
+
_EFFECTS[n] = skia.RuntimeEffect.MakeForShader(_SKSL.replace("DECLARE", decl).replace("LOOKUP", pick))
|
|
63
|
+
return _EFFECTS[n]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def light(spec) -> dict:
|
|
67
|
+
"""A light preset name or {key, fill, ambient, rim, from, color} on top of the studio preset."""
|
|
68
|
+
if isinstance(spec, dict):
|
|
69
|
+
out = dict(LIGHTS["studio"], color="#fff4e6", **LIGHTS.get(spec.get("preset"), {}))
|
|
70
|
+
out.update({k: v for k, v in spec.items() if k != "preset"})
|
|
71
|
+
return out
|
|
72
|
+
return dict(LIGHTS.get(spec or "studio", LIGHTS["studio"]), color="#fff4e6")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _unit(v) -> list[float]:
|
|
76
|
+
v = np.asarray(v, float)
|
|
77
|
+
return (v / (np.linalg.norm(v) or 1.0)).tolist()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _lights(lt: dict) -> tuple:
|
|
81
|
+
"""Key and fill directions in camera space, key colour, and the four amounts, as the shader takes them."""
|
|
82
|
+
key = _unit(FROM.get(lt.get("from"), FROM["topleft"]))
|
|
83
|
+
return (key, _unit((-key[0], 0.35, 0.8)), list(C.parse(lt.get("color", "#fff4e6"))[:3]),
|
|
84
|
+
[float(lt.get(k, 0)) for k in ("key", "fill", "ambient", "rim")])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _shade(nv, M, mats, key, fill, col, amt) -> np.ndarray:
|
|
88
|
+
"""The shader's lighting, per vertex, as 0xAARRGGBB: the raster backend's path."""
|
|
89
|
+
base = np.array([(*m[0], m[1]) for m in mats])[M]
|
|
90
|
+
sp, ex, metal, rim = np.array([m[2:] for m in mats])[M].T
|
|
91
|
+
n = np.c_[nv[:, :2], np.sqrt(np.clip(1 - (nv[:, :2] ** 2).sum(1), 0, 1))]
|
|
92
|
+
d, f = np.clip(n @ key, 0, None), np.clip(n @ fill, 0, None)
|
|
93
|
+
s = np.clip(n @ _unit(np.add(key, (0, 0, 1))), 0, None) ** ex * sp
|
|
94
|
+
b = base[:, 3]
|
|
95
|
+
toon = b > 1.5
|
|
96
|
+
d = np.where(toon, np.ceil(d * b - 0.25) / np.maximum(b, 1), d)
|
|
97
|
+
f = np.where(toon, np.ceil(f * b - 0.25) / np.maximum(b, 1), f)
|
|
98
|
+
s = np.where(toon, (s >= 0.45) * sp, s)
|
|
99
|
+
col = np.asarray(col)
|
|
100
|
+
light = amt[2] + amt[0] * d[:, None] * col + amt[1] * f[:, None] * (0.85, 0.9, 1.0)
|
|
101
|
+
rgb = base[:, :3] * light * (1 - 0.75 * metal)[:, None]
|
|
102
|
+
rgb += (1 - metal[:, None] + metal[:, None] * base[:, :3]) * (s * amt[0])[:, None] * col
|
|
103
|
+
rgb += ((1 - n[:, 2]) ** 3 * rim * amt[3])[:, None] * col
|
|
104
|
+
c = np.round(np.clip(rgb, 0, 1) * 255).astype(np.uint32)
|
|
105
|
+
return 0xFF000000 | (c[:, 0] << 16) | (c[:, 1] << 8) | c[:, 2]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _material(get) -> tuple:
|
|
109
|
+
col = get("fill")
|
|
110
|
+
col = C.parse(col) if isinstance(col, str) else col
|
|
111
|
+
shine, metal = float(get("shine", 0.4)), float(get("metal", 0.0))
|
|
112
|
+
return (tuple(col[:3]), float(get("bands", 0)), 0.6 * shine + 0.4 * metal, 6 + 90 * shine * shine, metal,
|
|
113
|
+
float(get("rim", 0.5)))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _text_path(r, num, raw) -> tuple[skia.Path, float]:
|
|
117
|
+
"""Outline of the text, centred on the origin (lines on their middle, capitals on theirs), and its size."""
|
|
118
|
+
size = float(num("size", 120))
|
|
119
|
+
fam = raw.get("font") or r.s.theme.get("font")
|
|
120
|
+
font = skia.Font(r._typeface(fam, int(num("weight", 600)), bool(raw.get("italic"))), size)
|
|
121
|
+
ls, lh = float(num("letterSpacing", 0)), size * float(num("lineHeight", 1.18))
|
|
122
|
+
lines = str(raw.get("text", "")).split("\n")
|
|
123
|
+
path = skia.Path()
|
|
124
|
+
for i, line in enumerate(lines):
|
|
125
|
+
glyphs = font.textToGlyphs(line)
|
|
126
|
+
xs = np.array(font.getXPos(glyphs)) + ls * np.arange(len(glyphs))
|
|
127
|
+
width = font.measureText(line) + ls * max(0, len(glyphs) - 1)
|
|
128
|
+
for gid, x in zip(glyphs, xs):
|
|
129
|
+
gp = font.getPath(gid)
|
|
130
|
+
if gp is not None:
|
|
131
|
+
path.addPath(gp, float(x) - width / 2, i * lh)
|
|
132
|
+
path.offset(0, font.getMetrics().fCapHeight / 2 - (len(lines) - 1) * lh / 2)
|
|
133
|
+
return path, size
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _svg_path(num, raw) -> tuple[skia.Path, float]:
|
|
137
|
+
"""The `d` outline (or its stroke's, with round ends, given strokeWidth) centred on the origin, scaled to
|
|
138
|
+
fit w and/or h when given, and its size."""
|
|
139
|
+
path = svgpath.parse(str(raw.get("d", "")))
|
|
140
|
+
sw = float(num("strokeWidth", 0) or 0)
|
|
141
|
+
if sw > 0:
|
|
142
|
+
outline = skia.Path()
|
|
143
|
+
skia.Paint(Style=skia.Paint.kStroke_Style, StrokeWidth=sw, StrokeCap=skia.Paint.kRound_Cap,
|
|
144
|
+
StrokeJoin=skia.Paint.kRound_Join).getFillPath(path, outline)
|
|
145
|
+
path = outline
|
|
146
|
+
b = path.computeTightBounds()
|
|
147
|
+
w, h = num("w", None), num("h", None)
|
|
148
|
+
fits = [float(v) / s for v, s in ((w, b.width()), (h, b.height())) if v is not None and s > 0]
|
|
149
|
+
s = min(fits) if fits else 1.0
|
|
150
|
+
fit = skia.Matrix()
|
|
151
|
+
fit.setScaleTranslate(s, s, -b.centerX() * s, -b.centerY() * s)
|
|
152
|
+
path.transform(fit)
|
|
153
|
+
return path, max(b.width(), b.height()) * s
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _extruded(r, shape: str, num, raw, detail: str) -> mesh.Mesh:
|
|
157
|
+
"""A text or path model's solid. Outlines are cached per text, font and size, solids per depth and bevel,
|
|
158
|
+
so a changing counter or an animated depth costs one rebuild per distinct value."""
|
|
159
|
+
k = mesh.DETAIL.get(detail, 1.0)
|
|
160
|
+
if shape == "text":
|
|
161
|
+
key = (shape, str(raw.get("text", "")), raw.get("font") or r.s.theme.get("font"), int(num("weight", 600)),
|
|
162
|
+
bool(raw.get("italic")), *(round(float(num(n, d)), 2) for n, d in
|
|
163
|
+
(("size", 120), ("letterSpacing", 0), ("lineHeight", 1.18))), k)
|
|
164
|
+
else:
|
|
165
|
+
key = (shape, str(raw.get("d", "")), num("w", None), num("h", None), num("strokeWidth", 0), k)
|
|
166
|
+
if len(r._solids) > 64:
|
|
167
|
+
r._solids.clear()
|
|
168
|
+
flat = r._solids.get(key)
|
|
169
|
+
if flat is None:
|
|
170
|
+
path, extent = _text_path(r, num, raw) if shape == "text" else _svg_path(num, raw)
|
|
171
|
+
seg = max(1.0, min(10.0, extent * 0.04)) / k # length of the pieces curves are cut into, in px
|
|
172
|
+
flat = r._solids[key] = (*mesh.shape2d(mesh.contours(path, seg)), seg, extent)
|
|
173
|
+
Q, starts, caps, seg, extent = flat
|
|
174
|
+
depth, bevel = float(num("depth", round(extent * 0.2))), float(num("bevel", 0))
|
|
175
|
+
full = key + (round(depth, 2), round(bevel, 2))
|
|
176
|
+
m = r._solids.get(full)
|
|
177
|
+
if m is None:
|
|
178
|
+
rows = max(1, min(4, round(depth / (3 * seg))))
|
|
179
|
+
# Caps are cut into triangles up to 6 pieces long: one long cap triangle sorts by its middle and
|
|
180
|
+
# can be drawn under walls it should cover (the hole of an O seen at an angle).
|
|
181
|
+
m = r._solids[full] = (mesh.extrude(Q, starts, caps, depth, bevel, rows, 6 * seg) if len(caps)
|
|
182
|
+
else mesh.Mesh(np.zeros((0, 3)), np.zeros((0, 3)), np.zeros((0, 3), np.int32),
|
|
183
|
+
np.zeros(0, np.int32)))
|
|
184
|
+
return m
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _shape(r, shape: str, num, raw, detail: str) -> mesh.Mesh:
|
|
188
|
+
"""One shape's mesh at its size, centred on the origin. num(key, default) reads its numbers, raw its text."""
|
|
189
|
+
if shape in mesh.EXTRUDED:
|
|
190
|
+
return _extruded(r, shape, num, raw, detail)
|
|
191
|
+
s3, tube = mesh.size(shape, num)
|
|
192
|
+
return mesh.placed(mesh.unit(shape, detail, tube), s3)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parts(r, L):
|
|
196
|
+
"""The model's parts merged once: mesh, and for each material slot the part keys that override the layer."""
|
|
197
|
+
hit = r._models.get(L.id)
|
|
198
|
+
if hit is None:
|
|
199
|
+
detail = L.raw.get("detail", "medium")
|
|
200
|
+
meshes, slots, keys = [], [], [{}]
|
|
201
|
+
for p in L.raw.get("parts") or []:
|
|
202
|
+
own = {k: p[k] for k in MATERIAL if k in p}
|
|
203
|
+
if own not in keys:
|
|
204
|
+
keys.append(own)
|
|
205
|
+
m = _shape(r, p.get("shape", "cube"), lambda k, d, p=p: p.get(k, d), p, detail)
|
|
206
|
+
meshes.append(mesh.placed(m, (1.0, 1.0, 1.0), mesh.part_matrix(p)))
|
|
207
|
+
slots.append(keys.index(own))
|
|
208
|
+
if len(keys) > MAX_MATERIALS:
|
|
209
|
+
raise ValueError(f"layer '{L.id}': {len(keys)} different materials; a model takes at most {MAX_MATERIALS}")
|
|
210
|
+
hit = r._models[L.id] = (mesh.merge(meshes, slots) if meshes else None, keys)
|
|
211
|
+
return hit
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _imported(r, L, taken: int):
|
|
215
|
+
"""The layer's model file: its mesh with its materials in the slots after the first `taken`, those
|
|
216
|
+
materials less the keys the layer sets itself (a layer fill recolours the whole model), and its size."""
|
|
217
|
+
hit = r._models.get(("src", L.id))
|
|
218
|
+
if hit is None:
|
|
219
|
+
imp = models.load(L.src)
|
|
220
|
+
M, mats = models.slots(imp, MAX_MATERIALS - taken)
|
|
221
|
+
own = [{k: v for k, v in m.items() if k not in L.raw} for m in mats]
|
|
222
|
+
m = imp.mesh._replace(M=np.where(M < 0, 0, M + taken).astype(np.int32))
|
|
223
|
+
hit = r._models[("src", L.id)] = (m, own, imp.size)
|
|
224
|
+
return hit
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fit(g, size) -> float:
|
|
228
|
+
"""Scale of a model file: it fits inside w and h keeping its proportions, or its longest side is 300 px."""
|
|
229
|
+
s = [float(v) / e for v, e in ((g("w", None), size[0]), (g("h", None), size[1])) if v is not None and e > 1e-9]
|
|
230
|
+
return min(s) if s else 300.0 / max(float(size.max()), 1e-9)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def model(r, L, g) -> tuple[mesh.Mesh, list[tuple]]:
|
|
234
|
+
"""The model's mesh in its own space at this frame, and its materials."""
|
|
235
|
+
parts, keys = _parts(r, L)
|
|
236
|
+
shape = L.raw.get("shape")
|
|
237
|
+
meshes = [] if parts is None else [parts]
|
|
238
|
+
if L.src:
|
|
239
|
+
m, own, size = _imported(r, L, len(keys))
|
|
240
|
+
keys = keys + own
|
|
241
|
+
meshes.insert(0, m._replace(P=m.P * _fit(g, size)))
|
|
242
|
+
elif shape:
|
|
243
|
+
meshes.insert(0, _shape(r, shape, g, L.raw, L.raw.get("detail", "medium")))
|
|
244
|
+
m = meshes[0] if len(meshes) == 1 else mesh.cat(meshes)
|
|
245
|
+
accent = r.s.theme.get("accent", "#ff633f")
|
|
246
|
+
root = lambda k, d=None: g(k, accent if k == "fill" and d is None else d) # noqa: E731
|
|
247
|
+
mats = [_material(lambda k, d=None, o=o: o.get(k, root(k, d))) for o in keys]
|
|
248
|
+
return m, mats
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _hull(p: np.ndarray) -> np.ndarray:
|
|
252
|
+
"""Convex outline of 2D points, from the farthest point in each of 48 directions, in order around it."""
|
|
253
|
+
a = np.linspace(0, 2 * np.pi, 48, endpoint=False)
|
|
254
|
+
h = p[np.unique(np.argmax(p @ np.stack([np.cos(a), np.sin(a)]), axis=0))]
|
|
255
|
+
mid = h.mean(0)
|
|
256
|
+
return h[np.argsort(np.arctan2(h[:, 1] - mid[1], h[:, 0] - mid[0]))]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _ground(r, c: skia.Canvas, spec, rest: np.ndarray, pts: np.ndarray):
|
|
260
|
+
"""A soft shadow under the model, as if lit from straight above: the footprint of its points on a level
|
|
261
|
+
floor, darkest where it touches and wider and fainter as it rises. The floor lies in the layer's parent
|
|
262
|
+
space, at the model's lowest point before it turns (`rest`), unless `y` places it."""
|
|
263
|
+
spec = spec if isinstance(spec, dict) else {}
|
|
264
|
+
local = np.linalg.solve(r._outer, r._m3d) # the layer's own transform
|
|
265
|
+
Q = pts @ local[:3, :3].T + local[:3, 3]
|
|
266
|
+
low = float(Q[:, 1].max()) # y runs down
|
|
267
|
+
floor = float(spec["y"]) if "y" in spec else local[1, 3] + rest[:, 1].max() * np.linalg.norm(local[:3, 1])
|
|
268
|
+
lift = max(0.0, floor - low)
|
|
269
|
+
xz = Q[:, [0, 2]]
|
|
270
|
+
span = float(np.ptp(xz, axis=0).max()) or 1.0
|
|
271
|
+
op, soft = float(spec.get("opacity", 0.5)), float(spec.get("blur", 0.08 * span))
|
|
272
|
+
# A wide soft shadow of the whole footprint, and a tight dark one of the part within reach of the floor.
|
|
273
|
+
touch = xz[Q[:, 1] > low - 0.04 * span]
|
|
274
|
+
shapes = [(_hull(p), sigma, alpha) for p, sigma, alpha in
|
|
275
|
+
((xz, soft + 0.3 * lift, 0.55 * op / (1 + 4 * lift / span)),
|
|
276
|
+
(touch, 0.25 * soft + 0.5 * lift, op * math.exp(-6 * lift / span))) if alpha >= 0.004 and len(p) >= 3]
|
|
277
|
+
if not shapes:
|
|
278
|
+
return
|
|
279
|
+
lo = np.min([h.min(0) - 3 * s for h, s, _ in shapes], axis=0)
|
|
280
|
+
hi = np.max([h.max(0) + 3 * s for h, s, _ in shapes], axis=0)
|
|
281
|
+
# Floor coordinates (x, z) to the parent's space; the unused third column keeps the matrix invertible.
|
|
282
|
+
to = r._outer @ np.array([[1.0, 0, 0, 0], [0, 0, -1, floor], [0, 1, 0, 0], [0, 0, 0, 1]])
|
|
283
|
+
box = np.array([[lo[0], lo[1], 0, 1], [hi[0], lo[1], 0, 1], [lo[0], hi[1], 0, 1], [hi[0], hi[1], 0, 1]])
|
|
284
|
+
if ((box @ to.T)[:, 3] < space.NEAR).any():
|
|
285
|
+
return # the floor passes behind the camera
|
|
286
|
+
# Blurred seen from above at a few floor px per pixel, then laid on the floor: a blur drawn under the
|
|
287
|
+
# camera's perspective added 36 ms to a CPU frame of examples/import.json, this adds about 3.
|
|
288
|
+
px = max(1.0, soft / 4)
|
|
289
|
+
w, h = (int(math.ceil(v)) for v in (hi - lo) / px)
|
|
290
|
+
surf = skia.Surface(max(1, w), max(1, h))
|
|
291
|
+
cv = surf.getCanvas()
|
|
292
|
+
cv.scale(1 / px, 1 / px)
|
|
293
|
+
cv.translate(-lo[0], -lo[1])
|
|
294
|
+
for hull, sigma, alpha in shapes:
|
|
295
|
+
path = skia.Path()
|
|
296
|
+
path.addPoly([skia.Point(*q) for q in hull.tolist()], True)
|
|
297
|
+
cv.drawPath(path, skia.Paint(AntiAlias=True, Color=skia.ColorSetARGB(round(255 * min(1.0, alpha)), 0, 0, 0),
|
|
298
|
+
MaskFilter=skia.MaskFilter.MakeBlur(skia.kNormal_BlurStyle, sigma)))
|
|
299
|
+
c.save()
|
|
300
|
+
c.resetMatrix()
|
|
301
|
+
c.scale(r.scale, r.scale)
|
|
302
|
+
c.concat(space.to_skia(to))
|
|
303
|
+
c.drawImageRect(surf.makeImageSnapshot(), skia.Rect(*lo, lo[0] + w * px, lo[1] + h * px),
|
|
304
|
+
skia.SamplingOptions(skia.FilterMode.kLinear))
|
|
305
|
+
c.restore()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def draw(r, c: skia.Canvas, L, g, reveal: float):
|
|
309
|
+
"""Draw model layer L on canvas c. Returns its projected bounds in device pixels, or None."""
|
|
310
|
+
m, mats = model(r, L, g)
|
|
311
|
+
P, N, T, M = m.P, m.N, m.T, m.M
|
|
312
|
+
amt = max(0.0, min(1.0, float(g("draw")) * reveal))
|
|
313
|
+
tid = np.arange(len(T)) # each triangle's index in the whole mesh
|
|
314
|
+
if amt < 1.0:
|
|
315
|
+
tid = mesh.build_order(m)[:math.ceil(amt * len(T))]
|
|
316
|
+
T = T[tid]
|
|
317
|
+
ex = float(g("explode", 0.0))
|
|
318
|
+
if ex > 0 and len(T):
|
|
319
|
+
# Each face flies out along its own normal, some further than others (a fixed per-face hash).
|
|
320
|
+
a, b, cc = P[T[:, 0]], P[T[:, 1]], P[T[:, 2]]
|
|
321
|
+
fn = np.cross(b - a, cc - a)
|
|
322
|
+
fn /= np.linalg.norm(fn, axis=1, keepdims=True)
|
|
323
|
+
jitter = 0.5 + 0.5 * np.modf(np.abs(np.sin(tid * 12.9898)) * 43758.5453)[0]
|
|
324
|
+
reach = ex * float(np.ptp(P, axis=0).max()) * 0.6 * jitter
|
|
325
|
+
P = (P[T] + (fn * reach[:, None])[:, None, :]).reshape(-1, 3)
|
|
326
|
+
N, M = N[T].reshape(-1, 3), M[T].reshape(-1)
|
|
327
|
+
T = np.arange(len(P), dtype=np.int32).reshape(-1, 3)
|
|
328
|
+
foot = P if amt >= 1.0 or ex > 0 else P[T.ravel()] # what casts the ground shadow: what is drawn
|
|
329
|
+
hom = P @ r._m3d[:3, :3].T + r._m3d[:3, 3]
|
|
330
|
+
w = P @ r._m3d[3, :3] + r._m3d[3, 3]
|
|
331
|
+
near = (w[T] < space.NEAR).any(1)
|
|
332
|
+
if near.any():
|
|
333
|
+
r.culled.add(L.id)
|
|
334
|
+
T = T[~near]
|
|
335
|
+
xy = hom[:, :2] / np.where(np.abs(w) < 1e-9, 1e-9, w)[:, None]
|
|
336
|
+
a, b, cc = xy[T[:, 0]], xy[T[:, 1]], xy[T[:, 2]]
|
|
337
|
+
T = T[(b[:, 0] - a[:, 0]) * (cc[:, 1] - a[:, 1]) - (b[:, 1] - a[:, 1]) * (cc[:, 0] - a[:, 0]) > 0]
|
|
338
|
+
if not len(T):
|
|
339
|
+
return None
|
|
340
|
+
T = T[np.argsort(-w[T].sum(1), kind="stable")]
|
|
341
|
+
r.tris += len(T)
|
|
342
|
+
# Normals into camera space: the layer matrix without the lens.
|
|
343
|
+
view = np.linalg.inv(r._lens) @ r._m3d
|
|
344
|
+
nv = N @ np.linalg.inv(view[:3, :3])
|
|
345
|
+
nv /= np.linalg.norm(nv, axis=1, keepdims=True)
|
|
346
|
+
dev = xy * r.scale
|
|
347
|
+
lo, hi = dev[T.ravel()].min(0), dev[T.ravel()].max(0)
|
|
348
|
+
bounds = [float(lo[0]), float(lo[1]), float(hi[0]), float(hi[1])]
|
|
349
|
+
x0, y0 = max(0, math.floor(lo[0]) - 2), max(0, math.floor(lo[1]) - 2)
|
|
350
|
+
x1, y1 = min(r.w, math.ceil(hi[0]) + 2), min(r.h, math.ceil(hi[1]) + 2)
|
|
351
|
+
if x1 <= x0 or y1 <= y0:
|
|
352
|
+
return bounds
|
|
353
|
+
surf, ss = r._model_surface(x1 - x0, y1 - y0)
|
|
354
|
+
region = skia.Rect(0, 0, (x1 - x0) * ss, (y1 - y0) * ss)
|
|
355
|
+
cv = surf.getCanvas()
|
|
356
|
+
cv.save()
|
|
357
|
+
cv.clipRect(region)
|
|
358
|
+
cv.clear(skia.ColorTRANSPARENT)
|
|
359
|
+
cv.restore()
|
|
360
|
+
q = (dev - (x0, y0)) * ss
|
|
361
|
+
lt = _lights(light(L.raw.get("light", r.s.light)))
|
|
362
|
+
if r.backend == "gpu":
|
|
363
|
+
bld = skia.RuntimeShaderBuilder(_effect(len(mats)))
|
|
364
|
+
for i, mt in enumerate(mats):
|
|
365
|
+
bld.setUniform(f"base{i}", [*mt[0], mt[1]])
|
|
366
|
+
bld.setUniform(f"surf{i}", list(mt[2:]))
|
|
367
|
+
for name, v in zip(("keyDir", "fillDir", "keyCol", "amt"), lt):
|
|
368
|
+
bld.setUniform(name, v)
|
|
369
|
+
paint, colors = skia.Paint(Shader=bld.makeShader()), None
|
|
370
|
+
u, v = 2.0 * M + 0.5 + 0.45 * nv[:, 0], nv[:, 1] # the normal, with the material slot packed in
|
|
371
|
+
else:
|
|
372
|
+
# The raster backend runs SkSL slowly: examples/model.json spent 275 ms a frame in drawVertices
|
|
373
|
+
# at 2x supersampling. Shading each vertex in numpy, the whole frame takes 72 ms.
|
|
374
|
+
paint, colors = skia.Paint(), _shade(nv, M, mats, *lt)
|
|
375
|
+
# Skia indexes vertices with 16 bits, and converts each one it is given (about 0.3 us): draw
|
|
376
|
+
# in sorted chunks, each with only the vertices its triangles use.
|
|
377
|
+
for k in range(0, len(T), 20000):
|
|
378
|
+
used, idx = np.unique(T[k:k + 20000].ravel(), return_inverse=True)
|
|
379
|
+
pos = list(map(skia.Point, q[used, 0].tolist(), q[used, 1].tolist()))
|
|
380
|
+
tex = None if colors is not None else list(map(skia.Point, u[used].tolist(), v[used].tolist()))
|
|
381
|
+
verts = skia.Vertices.MakeCopy(skia.Vertices.kTriangles_VertexMode, pos, tex,
|
|
382
|
+
None if colors is None else colors[used].tolist(), idx)
|
|
383
|
+
cv.drawVertices(verts, paint, skia.BlendMode.kDst) # kDst: vertex colours as given, when there are any
|
|
384
|
+
img = surf.makeImageSnapshot()
|
|
385
|
+
if L.raw.get("ground") not in (None, False):
|
|
386
|
+
_ground(r, c, L.raw["ground"], m.P, foot)
|
|
387
|
+
c.save()
|
|
388
|
+
c.resetMatrix()
|
|
389
|
+
sampling = skia.SamplingOptions(skia.FilterMode.kLinear) if ss > 1 else skia.SamplingOptions()
|
|
390
|
+
c.drawImageRect(img, region, skia.Rect(x0, y0, x1, y1), sampling)
|
|
391
|
+
c.restore()
|
|
392
|
+
del img # a live snapshot makes the next draw into the surface copy it first
|
|
393
|
+
return bounds
|