spryte 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/dist/backend.d.ts +69 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +2 -0
- package/dist/backend.js.map +1 -0
- package/dist/bitmap-font.d.ts +55 -0
- package/dist/bitmap-font.d.ts.map +1 -0
- package/dist/bitmap-font.js +103 -0
- package/dist/bitmap-font.js.map +1 -0
- package/dist/canvas2d.d.ts +45 -0
- package/dist/canvas2d.d.ts.map +1 -0
- package/dist/canvas2d.js +212 -0
- package/dist/canvas2d.js.map +1 -0
- package/dist/color.d.ts +7 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +49 -0
- package/dist/color.js.map +1 -0
- package/dist/copies.d.ts +45 -0
- package/dist/copies.d.ts.map +1 -0
- package/dist/copies.js +43 -0
- package/dist/copies.js.map +1 -0
- package/dist/draw.d.ts +188 -0
- package/dist/draw.d.ts.map +1 -0
- package/dist/draw.js +638 -0
- package/dist/draw.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/renderer.d.ts +17 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +61 -0
- package/dist/renderer.js.map +1 -0
- package/dist/shader.d.ts +34 -0
- package/dist/shader.d.ts.map +1 -0
- package/dist/shader.js +40 -0
- package/dist/shader.js.map +1 -0
- package/dist/target.d.ts +13 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +18 -0
- package/dist/target.js.map +1 -0
- package/dist/webgl2.d.ts +90 -0
- package/dist/webgl2.d.ts.map +1 -0
- package/dist/webgl2.js +893 -0
- package/dist/webgl2.js.map +1 -0
- package/package.json +54 -4
package/dist/draw.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { toRGBA } from "./color.js";
|
|
2
|
+
import { Shader } from "./shader.js";
|
|
3
|
+
import { RenderTarget } from "./target.js";
|
|
4
|
+
const NO_OPTIONS = Object.freeze({});
|
|
5
|
+
const WHITE = 0xffffffff;
|
|
6
|
+
/**
|
|
7
|
+
* Immediate-mode drawing in logical units, the same API on WebGL2 and Canvas 2D (ADR 0007).
|
|
8
|
+
* Everything batches automatically; call order is draw order.
|
|
9
|
+
*/
|
|
10
|
+
export class Draw {
|
|
11
|
+
backend;
|
|
12
|
+
smooth;
|
|
13
|
+
/** Global alpha multiplier for subsequent draws. */
|
|
14
|
+
alpha = 1;
|
|
15
|
+
/**
|
|
16
|
+
* Font for `text()` and `measure()` when none is given: a CSS font, a `BitmapFont`, or a function returning
|
|
17
|
+
* one (called on first use, so a font built from a canvas costs nothing until text is drawn).
|
|
18
|
+
*/
|
|
19
|
+
font = "16px sans-serif";
|
|
20
|
+
/** Quads (sprites, rects, glyph runs…) submitted this frame. Reset each frame; for diagnostics. */
|
|
21
|
+
quads = 0;
|
|
22
|
+
/** Preallocated transform stack: push/pop copy six numbers and never allocate. */
|
|
23
|
+
stack = [];
|
|
24
|
+
depth = 0;
|
|
25
|
+
m = [1, 0, 0, 1, 0, 0];
|
|
26
|
+
frames = new WeakMap();
|
|
27
|
+
lastImage = null;
|
|
28
|
+
lastFrame;
|
|
29
|
+
clips = [];
|
|
30
|
+
shader = null;
|
|
31
|
+
/** The render target being drawn into, or null for the canvas. */
|
|
32
|
+
target = null;
|
|
33
|
+
/** The canvas's logical size this frame, to switch back to after `into()`. */
|
|
34
|
+
viewW = 1;
|
|
35
|
+
viewH = 1;
|
|
36
|
+
tmp = [1, 0, 0, 1, 0, 0];
|
|
37
|
+
textCache = new TextCache();
|
|
38
|
+
circleTexture;
|
|
39
|
+
backingW = 0;
|
|
40
|
+
backingH = 0;
|
|
41
|
+
/** @internal */
|
|
42
|
+
constructor(backend,
|
|
43
|
+
/** Default filtering for images (false for pixel art). */
|
|
44
|
+
smooth) {
|
|
45
|
+
this.backend = backend;
|
|
46
|
+
this.smooth = smooth;
|
|
47
|
+
}
|
|
48
|
+
/** Which backend is active. */
|
|
49
|
+
get kind() {
|
|
50
|
+
return this.backend.kind;
|
|
51
|
+
}
|
|
52
|
+
/** Draw calls this frame: batched GPU calls on WebGL2, one per quad on Canvas 2D. */
|
|
53
|
+
get drawCalls() {
|
|
54
|
+
return this.backend.drawCalls ?? this.quads;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolves once the backend can draw. Immediate on WebGL2; on the Canvas 2D fallback, once its chunk has
|
|
58
|
+
* loaded (frames before that draw nothing). Rejects if the fallback fails to load.
|
|
59
|
+
*/
|
|
60
|
+
ready = Promise.resolve();
|
|
61
|
+
/** Raw `WebGL2RenderingContext` or `CanvasRenderingContext2D`. Mixing raw calls with `draw` calls requires care. */
|
|
62
|
+
get raw() {
|
|
63
|
+
return this.backend.raw;
|
|
64
|
+
}
|
|
65
|
+
// ---- transform stack -------------------------------------------------------------------
|
|
66
|
+
push() {
|
|
67
|
+
let slot = this.stack[this.depth];
|
|
68
|
+
if (!slot) {
|
|
69
|
+
slot = [1, 0, 0, 1, 0, 0];
|
|
70
|
+
this.stack[this.depth] = slot;
|
|
71
|
+
}
|
|
72
|
+
copy(this.m, slot);
|
|
73
|
+
this.depth++;
|
|
74
|
+
}
|
|
75
|
+
pop() {
|
|
76
|
+
if (this.depth === 0)
|
|
77
|
+
throw new Error("draw.pop() without a matching push()");
|
|
78
|
+
this.depth--;
|
|
79
|
+
copy(this.stack[this.depth], this.m);
|
|
80
|
+
}
|
|
81
|
+
translate(x, y) {
|
|
82
|
+
const m = this.m;
|
|
83
|
+
m[4] += m[0] * x + m[2] * y;
|
|
84
|
+
m[5] += m[1] * x + m[3] * y;
|
|
85
|
+
}
|
|
86
|
+
scale(x, y = x) {
|
|
87
|
+
const m = this.m;
|
|
88
|
+
m[0] *= x;
|
|
89
|
+
m[1] *= x;
|
|
90
|
+
m[2] *= y;
|
|
91
|
+
m[3] *= y;
|
|
92
|
+
}
|
|
93
|
+
rotate(radians) {
|
|
94
|
+
const c = Math.cos(radians);
|
|
95
|
+
const s = Math.sin(radians);
|
|
96
|
+
const [a, b, cc, d] = this.m;
|
|
97
|
+
this.m[0] = a * c + cc * s;
|
|
98
|
+
this.m[1] = b * c + d * s;
|
|
99
|
+
this.m[2] = cc * c - a * s;
|
|
100
|
+
this.m[3] = d * c - b * s;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Run `fn` with drawing restricted to a rectangle (in current units), then restore. Nested clips
|
|
104
|
+
* intersect. Clips are axis-aligned on screen: under rotation the rectangle's screen bounds are used.
|
|
105
|
+
*/
|
|
106
|
+
clip(x, y, w, h, fn) {
|
|
107
|
+
const m = this.m;
|
|
108
|
+
const s = this.renderScale;
|
|
109
|
+
let x0 = Number.POSITIVE_INFINITY;
|
|
110
|
+
let y0 = Number.POSITIVE_INFINITY;
|
|
111
|
+
let x1 = Number.NEGATIVE_INFINITY;
|
|
112
|
+
let y1 = Number.NEGATIVE_INFINITY;
|
|
113
|
+
for (const [px, py] of [
|
|
114
|
+
[x, y],
|
|
115
|
+
[x + w, y],
|
|
116
|
+
[x, y + h],
|
|
117
|
+
[x + w, y + h],
|
|
118
|
+
]) {
|
|
119
|
+
const sx = (m[0] * px + m[2] * py + m[4]) * s;
|
|
120
|
+
const sy = (m[1] * px + m[3] * py + m[5]) * s;
|
|
121
|
+
x0 = Math.min(x0, sx);
|
|
122
|
+
y0 = Math.min(y0, sy);
|
|
123
|
+
x1 = Math.max(x1, sx);
|
|
124
|
+
y1 = Math.max(y1, sy);
|
|
125
|
+
}
|
|
126
|
+
let rect = {
|
|
127
|
+
x: Math.round(x0),
|
|
128
|
+
y: Math.round(y0),
|
|
129
|
+
w: Math.max(0, Math.round(x1) - Math.round(x0)),
|
|
130
|
+
h: Math.max(0, Math.round(y1) - Math.round(y0)),
|
|
131
|
+
};
|
|
132
|
+
const outer = this.clips.at(-1);
|
|
133
|
+
if (outer)
|
|
134
|
+
rect = intersect(rect, outer);
|
|
135
|
+
this.clips.push(rect);
|
|
136
|
+
this.backend.setClip(rect);
|
|
137
|
+
try {
|
|
138
|
+
fn();
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
this.clips.pop();
|
|
142
|
+
this.backend.setClip(this.clips.at(-1) ?? null);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Run `fn` with the camera's view applied, then restore. */
|
|
146
|
+
withCamera(camera, fn) {
|
|
147
|
+
this.push();
|
|
148
|
+
const [a, b, c, d, e, f] = camera.view();
|
|
149
|
+
const m = this.m;
|
|
150
|
+
const [ma, mb, mc, md, me, mf] = m;
|
|
151
|
+
m[0] = ma * a + mc * b;
|
|
152
|
+
m[1] = mb * a + md * b;
|
|
153
|
+
m[2] = ma * c + mc * d;
|
|
154
|
+
m[3] = mb * c + md * d;
|
|
155
|
+
m[4] = ma * e + mc * f + me;
|
|
156
|
+
m[5] = mb * e + md * f + mf;
|
|
157
|
+
try {
|
|
158
|
+
fn();
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
this.pop();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ---- drawing ---------------------------------------------------------------------------
|
|
165
|
+
blend(mode) {
|
|
166
|
+
this.backend.setBlend(mode);
|
|
167
|
+
}
|
|
168
|
+
rect(x, y, w, h, color) {
|
|
169
|
+
this.local(x, y, w, h);
|
|
170
|
+
this.quads++;
|
|
171
|
+
this.backend.quad(this.tmp, null, 0, 0, 1, 1, this.withAlpha(toRGBA(color), 1), 0, false);
|
|
172
|
+
}
|
|
173
|
+
/** Filled circle (antialiased). */
|
|
174
|
+
circle(x, y, radius, color) {
|
|
175
|
+
this.local(x - radius, y - radius, radius * 2, radius * 2);
|
|
176
|
+
const tex = this.circle64();
|
|
177
|
+
this.quads++;
|
|
178
|
+
this.backend.quad(this.tmp, tex, 0, 0, 64, 64, this.withAlpha(toRGBA(color), 1), 0, true);
|
|
179
|
+
}
|
|
180
|
+
line(x1, y1, x2, y2, width, color) {
|
|
181
|
+
const dx = x2 - x1;
|
|
182
|
+
const dy = y2 - y1;
|
|
183
|
+
const len = Math.hypot(dx, dy);
|
|
184
|
+
if (len === 0)
|
|
185
|
+
return;
|
|
186
|
+
this.push();
|
|
187
|
+
this.translate(x1, y1);
|
|
188
|
+
this.rotate(Math.atan2(dy, dx));
|
|
189
|
+
this.rect(0, -width / 2, len, width, color);
|
|
190
|
+
this.pop();
|
|
191
|
+
}
|
|
192
|
+
/** Draw an image frame (or a whole image) at `x, y`, anchored by `origin`. Allocation-free hot path. */
|
|
193
|
+
sprite(frame, x, y, o = NO_OPTIONS) {
|
|
194
|
+
const f = this.place(frame, x, y, o);
|
|
195
|
+
const t = this.tmp;
|
|
196
|
+
const smooth = o.smooth ?? this.smooth;
|
|
197
|
+
if (!smooth && t[1] === 0 && t[2] === 0) {
|
|
198
|
+
// Pixel art: put unrotated sprites on whole pixels. Nearest sampling at fractional positions
|
|
199
|
+
// makes edges gain and lose a pixel from frame to frame, which reads as flicker.
|
|
200
|
+
t[4] = Math.round(t[4]);
|
|
201
|
+
t[5] = Math.round(t[5]);
|
|
202
|
+
}
|
|
203
|
+
this.quads++;
|
|
204
|
+
this.backend.quad(t, f.image, f.x, f.y, f.w, f.h, this.spriteColor(o), o.flash ?? 0, smooth);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Draw one image frame at many positions in a single call: particles, bullets, swarms, starfields.
|
|
208
|
+
* `positions` holds x, y pairs (a `Float32Array` you reuse is ideal); `count` defaults to all of them.
|
|
209
|
+
* Every copy shares `options` (rotation, scale, tint, alpha…), so per-copy work is just the position.
|
|
210
|
+
* Much faster than calling `sprite()` in a loop, with no per-sprite allocation.
|
|
211
|
+
*
|
|
212
|
+
* ```ts
|
|
213
|
+
* const pos = new Float32Array(2 * MAX);
|
|
214
|
+
* // ...update pos...
|
|
215
|
+
* draw.sprites(spark, pos, live, { tint: "#ffd36b", alpha: 0.8 });
|
|
216
|
+
* ```
|
|
217
|
+
*/
|
|
218
|
+
sprites(frame, positions, count = positions.length >> 1, o = NO_OPTIONS, each) {
|
|
219
|
+
// Never read past the arrays: GL would reject the whole upload and draw nothing.
|
|
220
|
+
count = Math.min(Math.floor(count) || 0, positions.length >> 1);
|
|
221
|
+
if (each) {
|
|
222
|
+
if (each.scale)
|
|
223
|
+
count = Math.min(count, each.scale.length);
|
|
224
|
+
if (each.rotation)
|
|
225
|
+
count = Math.min(count, each.rotation.length);
|
|
226
|
+
if (each.color)
|
|
227
|
+
count = Math.min(count, each.color.length);
|
|
228
|
+
}
|
|
229
|
+
if (count <= 0)
|
|
230
|
+
return;
|
|
231
|
+
if (each && (each.scale || each.rotation || each.color)) {
|
|
232
|
+
this.spritesEach(frame, positions, count, o, each);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const f = this.place(frame, 0, 0, o);
|
|
236
|
+
const t = this.tmp;
|
|
237
|
+
const smooth = o.smooth ?? this.smooth;
|
|
238
|
+
// Snapped per copy, like sprite().
|
|
239
|
+
const snap = !smooth && t[1] === 0 && t[2] === 0;
|
|
240
|
+
this.quads += count;
|
|
241
|
+
this.backend.quads(t, this.m, positions, count, f.image, f.x, f.y, f.w, f.h, this.spriteColor(o), o.flash ?? 0, smooth, snap);
|
|
242
|
+
}
|
|
243
|
+
spritesEach(frame, positions, count, o, each) {
|
|
244
|
+
const f = frame === this.lastImage && this.lastFrame ? this.lastFrame : "image" in frame ? frame : this.wholeImage(frame);
|
|
245
|
+
const origin = o.origin;
|
|
246
|
+
const rotation = o.rotation ?? 0;
|
|
247
|
+
const smooth = o.smooth ?? this.smooth;
|
|
248
|
+
const m = this.m;
|
|
249
|
+
this.quads += count;
|
|
250
|
+
this.backend.copies({
|
|
251
|
+
m,
|
|
252
|
+
positions,
|
|
253
|
+
count,
|
|
254
|
+
src: f.image,
|
|
255
|
+
sx: f.x,
|
|
256
|
+
sy: f.y,
|
|
257
|
+
sw: f.w,
|
|
258
|
+
sh: f.h,
|
|
259
|
+
ox: -(origin ? origin[0] : 0.5) * f.w,
|
|
260
|
+
oy: -(origin ? origin[1] : 0.5) * f.h,
|
|
261
|
+
scaleX: (o.scaleX ?? 1) * (o.flipX ? -1 : 1),
|
|
262
|
+
scaleY: (o.scaleY ?? 1) * (o.flipY ? -1 : 1),
|
|
263
|
+
rotation,
|
|
264
|
+
color: this.spriteColor(o),
|
|
265
|
+
flash: o.flash ?? 0,
|
|
266
|
+
smooth,
|
|
267
|
+
// Like sprite(): per copy, whole pixels when that copy doesn't rotate.
|
|
268
|
+
snap: !smooth && m[1] === 0 && m[2] === 0,
|
|
269
|
+
each,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** tmp = M · T(x, y) · R(rotation) · S(sx, sy) · T(ox, oy) · S(w, h), expanded by hand. Returns the frame. */
|
|
273
|
+
place(frame, x, y, o) {
|
|
274
|
+
// Check the repeat-image cache before `in`: on a DOM image, `in` walks a long prototype chain per call.
|
|
275
|
+
const f = frame === this.lastImage && this.lastFrame ? this.lastFrame : "image" in frame ? frame : this.wholeImage(frame);
|
|
276
|
+
const origin = o.origin;
|
|
277
|
+
const ox = -(origin ? origin[0] : 0.5) * f.w;
|
|
278
|
+
const oy = -(origin ? origin[1] : 0.5) * f.h;
|
|
279
|
+
const sx = (o.scaleX ?? 1) * (o.flipX ? -1 : 1);
|
|
280
|
+
const sy = (o.scaleY ?? 1) * (o.flipY ? -1 : 1);
|
|
281
|
+
const m = this.m;
|
|
282
|
+
let a = sx;
|
|
283
|
+
let b = 0;
|
|
284
|
+
let c = 0;
|
|
285
|
+
let d = sy;
|
|
286
|
+
const r = o.rotation;
|
|
287
|
+
if (r) {
|
|
288
|
+
const cos = Math.cos(r);
|
|
289
|
+
const sin = Math.sin(r);
|
|
290
|
+
a = cos * sx;
|
|
291
|
+
b = sin * sx;
|
|
292
|
+
c = -sin * sy;
|
|
293
|
+
d = cos * sy;
|
|
294
|
+
}
|
|
295
|
+
const lx = a * ox + c * oy + x;
|
|
296
|
+
const ly = b * ox + d * oy + y;
|
|
297
|
+
const t = this.tmp;
|
|
298
|
+
t[0] = (m[0] * a + m[2] * b) * f.w;
|
|
299
|
+
t[1] = (m[1] * a + m[3] * b) * f.w;
|
|
300
|
+
t[2] = (m[0] * c + m[2] * d) * f.h;
|
|
301
|
+
t[3] = (m[1] * c + m[3] * d) * f.h;
|
|
302
|
+
t[4] = m[0] * lx + m[2] * ly + m[4];
|
|
303
|
+
t[5] = m[1] * lx + m[3] * ly + m[5];
|
|
304
|
+
return f;
|
|
305
|
+
}
|
|
306
|
+
spriteColor(o) {
|
|
307
|
+
const tint = o.tint === undefined ? WHITE : toRGBA(o.tint);
|
|
308
|
+
const alpha = o.alpha ?? 1;
|
|
309
|
+
return alpha === 1 && this.alpha === 1 ? tint : this.withAlpha(tint, alpha);
|
|
310
|
+
}
|
|
311
|
+
/** Text, rasterised once and cached as a texture (crisp at the current render scale). */
|
|
312
|
+
text(text, x, y, o = {}) {
|
|
313
|
+
if (!text)
|
|
314
|
+
return;
|
|
315
|
+
const font = o.font ?? this.defaultFont();
|
|
316
|
+
if (typeof font !== "string") {
|
|
317
|
+
this.bitmapText(text, x, y, font, o);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const entry = this.textCache.get(text, font, this.renderScale, (src) => this.backend.invalidate(src));
|
|
321
|
+
const ax = o.align === "center" ? 0.5 : o.align === "right" ? 1 : 0;
|
|
322
|
+
const ay = o.baseline === "middle" ? 0.5 : o.baseline === "bottom" ? 1 : 0;
|
|
323
|
+
this.local(x - ax * entry.w, y - ay * entry.h, entry.w, entry.h);
|
|
324
|
+
const color = this.withAlpha(toRGBA(o.color ?? "#ffffff"), o.alpha ?? 1);
|
|
325
|
+
this.quads++;
|
|
326
|
+
this.backend.quad(this.tmp, entry.canvas, 0, 0, entry.canvas.width, entry.canvas.height, color, 0, true);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The logical box `text()` fills for this string and font, so layouts can align and centre without
|
|
330
|
+
* guessing. Same cache as `text()`, so measuring then drawing rasterises once.
|
|
331
|
+
*/
|
|
332
|
+
measure(text, font = this.defaultFont()) {
|
|
333
|
+
if (!text)
|
|
334
|
+
return { w: 0, h: 0 };
|
|
335
|
+
if (typeof font !== "string")
|
|
336
|
+
return font.measure(text);
|
|
337
|
+
const entry = this.textCache.get(text, font, this.renderScale, (src) => this.backend.invalidate(src));
|
|
338
|
+
return { w: entry.w, h: entry.h };
|
|
339
|
+
}
|
|
340
|
+
bitmapText(text, x, y, font, o) {
|
|
341
|
+
const size = font.measure(text);
|
|
342
|
+
const ay = o.baseline === "middle" ? 0.5 : o.baseline === "bottom" ? 1 : 0;
|
|
343
|
+
const color = this.withAlpha(toRGBA(o.color ?? "#ffffff"), o.alpha ?? 1);
|
|
344
|
+
// Whole-pixel origin: a glyph straddling a pixel boundary would blur or drop a column.
|
|
345
|
+
let ly = Math.round(y - ay * size.h);
|
|
346
|
+
for (const line of text.split("\n")) {
|
|
347
|
+
const w = font.lineWidth(line);
|
|
348
|
+
let lx = Math.round(x - (o.align === "center" ? Math.floor(w / 2) : o.align === "right" ? w : 0));
|
|
349
|
+
for (const ch of line) {
|
|
350
|
+
const g = font.glyph(ch);
|
|
351
|
+
if (!g)
|
|
352
|
+
continue;
|
|
353
|
+
const f = g.frame;
|
|
354
|
+
this.local(lx, ly, f.w, f.h);
|
|
355
|
+
this.quads++;
|
|
356
|
+
this.backend.quad(this.tmp, f.image, f.x, f.y, f.w, f.h, color, 0, false);
|
|
357
|
+
lx += g.advance + font.spacing;
|
|
358
|
+
}
|
|
359
|
+
ly += font.lineHeight;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// ---- render targets ----------------------------------------------------------------------
|
|
363
|
+
/** An offscreen image of `width × height` pixels to draw into with `into()`, then draw like any image. */
|
|
364
|
+
createTarget(width, height) {
|
|
365
|
+
return new RenderTarget(width, height);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Run `fn` with drawing going into `target` instead of the canvas, then switch back. Inside, units are the
|
|
369
|
+
* target's pixels from its top-left; the transform, clips and global alpha start fresh and are restored
|
|
370
|
+
* afterwards. `clear` fills the target first (default transparent); pass `null` to draw over what's there,
|
|
371
|
+
* for trails and accumulation. Nests.
|
|
372
|
+
*
|
|
373
|
+
* ```ts
|
|
374
|
+
* const glow = draw.createTarget(160, 90);
|
|
375
|
+
* draw.into(glow, () => draw.sprites(spark, sparks, n, { tint: "#ffd36b" }));
|
|
376
|
+
* draw.blend("add");
|
|
377
|
+
* draw.sprite(glow, 160, 90, { scaleX: 2, scaleY: 2, smooth: true });
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
into(target, fn, clear = 0) {
|
|
381
|
+
const outer = this.target;
|
|
382
|
+
const outerW = outer ? outer.width : this.viewW;
|
|
383
|
+
const outerH = outer ? outer.height : this.viewH;
|
|
384
|
+
const outerScale = this.renderScale;
|
|
385
|
+
const outerAlpha = this.alpha;
|
|
386
|
+
const outerClips = this.clips.splice(0);
|
|
387
|
+
if (outerClips.length)
|
|
388
|
+
this.backend.setClip(null);
|
|
389
|
+
const depth = this.depth;
|
|
390
|
+
this.push();
|
|
391
|
+
copy(IDENTITY, this.m);
|
|
392
|
+
this.alpha = 1;
|
|
393
|
+
this.renderScale = 1;
|
|
394
|
+
this.target = target;
|
|
395
|
+
this.backend.setTarget(target, clear === null ? null : toRGBA(clear), target.width, target.height);
|
|
396
|
+
try {
|
|
397
|
+
fn();
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
if (this.clips.length) {
|
|
401
|
+
this.clips.length = 0;
|
|
402
|
+
this.backend.setClip(null);
|
|
403
|
+
}
|
|
404
|
+
this.target = outer;
|
|
405
|
+
this.backend.setTarget(outer, null, outerW, outerH);
|
|
406
|
+
this.clips.push(...outerClips);
|
|
407
|
+
const clip = outerClips.at(-1);
|
|
408
|
+
if (clip)
|
|
409
|
+
this.backend.setClip(clip);
|
|
410
|
+
// Unbalanced push() calls inside fn don't leak out.
|
|
411
|
+
this.depth = depth + 1;
|
|
412
|
+
this.pop();
|
|
413
|
+
this.alpha = outerAlpha;
|
|
414
|
+
this.renderScale = outerScale;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// ---- shaders ------------------------------------------------------------------------------
|
|
418
|
+
/** A custom fragment effect: see `Shader`. Compiled on first use, so errors surface at the first `withShader` draw. */
|
|
419
|
+
createShader(source) {
|
|
420
|
+
return new Shader(source);
|
|
421
|
+
}
|
|
422
|
+
/** Run `fn` with everything drawn through `shader`, then restore the previous one. Nests. WebGL2 only (see `Shader`). */
|
|
423
|
+
withShader(shader, fn) {
|
|
424
|
+
const outer = this.shader;
|
|
425
|
+
// Switch the backend first: if the shader fails to compile, nothing has changed.
|
|
426
|
+
this.backend.setShader(shader);
|
|
427
|
+
this.shader = shader;
|
|
428
|
+
try {
|
|
429
|
+
fn();
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
this.shader = outer;
|
|
433
|
+
this.backend.setShader(outer);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** Free a target's GPU memory. Drawing it afterwards recreates it, blank. */
|
|
437
|
+
disposeTarget(target) {
|
|
438
|
+
this.backend.disposeTarget(target);
|
|
439
|
+
}
|
|
440
|
+
/** True while the WebGL context is lost (e.g. a backgrounded mobile tab): frames draw nothing until it's restored. */
|
|
441
|
+
get contextLost() {
|
|
442
|
+
return this.backend.contextLost ?? false;
|
|
443
|
+
}
|
|
444
|
+
/** Re-upload a source whose pixels changed (e.g. a canvas you draw into). */
|
|
445
|
+
invalidate(src) {
|
|
446
|
+
this.backend.invalidate(src);
|
|
447
|
+
}
|
|
448
|
+
// ---- frame lifecycle (called by Game) ----------------------------------------------------
|
|
449
|
+
/** @internal */
|
|
450
|
+
renderScale = 1;
|
|
451
|
+
/** @internal */
|
|
452
|
+
begin(clear, width, height, scale) {
|
|
453
|
+
this.renderScale = scale;
|
|
454
|
+
this.viewW = width;
|
|
455
|
+
this.viewH = height;
|
|
456
|
+
this.target = null;
|
|
457
|
+
this.shader = null;
|
|
458
|
+
this.quads = 0;
|
|
459
|
+
this.depth = 0;
|
|
460
|
+
this.clips.length = 0;
|
|
461
|
+
copy(IDENTITY, this.m);
|
|
462
|
+
this.alpha = 1;
|
|
463
|
+
this.backend.setBlend("normal");
|
|
464
|
+
this.backend.begin(toRGBA(clear), width, height, scale);
|
|
465
|
+
}
|
|
466
|
+
/** @internal Reset transform and blend between scene and afterRender hooks. */
|
|
467
|
+
reset() {
|
|
468
|
+
this.depth = 0;
|
|
469
|
+
if (this.clips.length) {
|
|
470
|
+
this.clips.length = 0;
|
|
471
|
+
this.backend.setClip(null);
|
|
472
|
+
}
|
|
473
|
+
copy(IDENTITY, this.m);
|
|
474
|
+
this.alpha = 1;
|
|
475
|
+
this.backend.setBlend("normal");
|
|
476
|
+
}
|
|
477
|
+
/** @internal */
|
|
478
|
+
end() {
|
|
479
|
+
this.backend.end();
|
|
480
|
+
}
|
|
481
|
+
/** Release GPU resources and listeners. Using this `Draw` afterwards throws; disposing again does nothing. */
|
|
482
|
+
dispose() {
|
|
483
|
+
this.backend.dispose();
|
|
484
|
+
this.backend = disposed(this.backend);
|
|
485
|
+
}
|
|
486
|
+
/** @internal */
|
|
487
|
+
resize(w, h) {
|
|
488
|
+
this.backingW = w;
|
|
489
|
+
this.backingH = h;
|
|
490
|
+
this.backend.resize(w, h);
|
|
491
|
+
}
|
|
492
|
+
/** @internal Switch to a backend that finished loading (the Canvas 2D fallback), at the current size. */
|
|
493
|
+
setBackend(backend) {
|
|
494
|
+
this.backend.dispose();
|
|
495
|
+
this.backend = backend;
|
|
496
|
+
if (this.backingW)
|
|
497
|
+
backend.resize(this.backingW, this.backingH);
|
|
498
|
+
}
|
|
499
|
+
// ---- internals ---------------------------------------------------------------------------
|
|
500
|
+
/** tmp = current matrix × (translate(x, y) · scale(w, h)): maps the unit square to the rect. */
|
|
501
|
+
local(x, y, w, h) {
|
|
502
|
+
const m = this.m;
|
|
503
|
+
const t = this.tmp;
|
|
504
|
+
t[0] = m[0] * w;
|
|
505
|
+
t[1] = m[1] * w;
|
|
506
|
+
t[2] = m[2] * h;
|
|
507
|
+
t[3] = m[3] * h;
|
|
508
|
+
t[4] = m[0] * x + m[2] * y + m[4];
|
|
509
|
+
t[5] = m[1] * x + m[3] * y + m[5];
|
|
510
|
+
}
|
|
511
|
+
wholeImage(image) {
|
|
512
|
+
// Loops usually draw one image many times in a row; skip the WeakMap for repeats.
|
|
513
|
+
if (image === this.lastImage && this.lastFrame)
|
|
514
|
+
return this.lastFrame;
|
|
515
|
+
let f = this.frames.get(image);
|
|
516
|
+
if (!f) {
|
|
517
|
+
f = wholeImage(image);
|
|
518
|
+
this.frames.set(image, f);
|
|
519
|
+
}
|
|
520
|
+
this.lastImage = image;
|
|
521
|
+
this.lastFrame = f;
|
|
522
|
+
return f;
|
|
523
|
+
}
|
|
524
|
+
defaultFont() {
|
|
525
|
+
return typeof this.font === "function" ? this.font() : this.font;
|
|
526
|
+
}
|
|
527
|
+
withAlpha(rgba, alpha) {
|
|
528
|
+
const a = Math.round((rgba & 0xff) * alpha * this.alpha);
|
|
529
|
+
return ((rgba & 0xffffff00) | Math.max(0, Math.min(255, a))) >>> 0;
|
|
530
|
+
}
|
|
531
|
+
circle64() {
|
|
532
|
+
if (!this.circleTexture) {
|
|
533
|
+
const c = makeCanvas(64, 64);
|
|
534
|
+
const ctx = c.getContext("2d");
|
|
535
|
+
if (ctx) {
|
|
536
|
+
ctx.fillStyle = "#fff";
|
|
537
|
+
ctx.beginPath();
|
|
538
|
+
ctx.arc(32, 32, 31.5, 0, Math.PI * 2);
|
|
539
|
+
ctx.fill();
|
|
540
|
+
}
|
|
541
|
+
this.circleTexture = c;
|
|
542
|
+
}
|
|
543
|
+
return this.circleTexture;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
547
|
+
/** Stands in for a disposed backend: any drawing throws a clear error instead of touching dead GPU objects. */
|
|
548
|
+
function disposed(was) {
|
|
549
|
+
const fail = () => {
|
|
550
|
+
throw new Error("spryte: this renderer was disposed; create a new one with createRenderer()");
|
|
551
|
+
};
|
|
552
|
+
return {
|
|
553
|
+
kind: was.kind,
|
|
554
|
+
raw: was.raw,
|
|
555
|
+
begin: fail,
|
|
556
|
+
quad: fail,
|
|
557
|
+
quads: fail,
|
|
558
|
+
copies: fail,
|
|
559
|
+
setBlend: fail,
|
|
560
|
+
setClip: fail,
|
|
561
|
+
setTarget: fail,
|
|
562
|
+
setShader: fail,
|
|
563
|
+
disposeTarget: () => { },
|
|
564
|
+
end: fail,
|
|
565
|
+
resize: () => { },
|
|
566
|
+
invalidate: () => { },
|
|
567
|
+
dispose: () => { },
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function intersect(a, b) {
|
|
571
|
+
const x = Math.max(a.x, b.x);
|
|
572
|
+
const y = Math.max(a.y, b.y);
|
|
573
|
+
return { x, y, w: Math.max(0, Math.min(a.x + a.w, b.x + b.w) - x), h: Math.max(0, Math.min(a.y + a.h, b.y + b.h) - y) };
|
|
574
|
+
}
|
|
575
|
+
function copy(from, to) {
|
|
576
|
+
to[0] = from[0];
|
|
577
|
+
to[1] = from[1];
|
|
578
|
+
to[2] = from[2];
|
|
579
|
+
to[3] = from[3];
|
|
580
|
+
to[4] = from[4];
|
|
581
|
+
to[5] = from[5];
|
|
582
|
+
}
|
|
583
|
+
function wholeImage(image) {
|
|
584
|
+
const w = "naturalWidth" in image ? image.naturalWidth : image.width;
|
|
585
|
+
const h = "naturalHeight" in image ? image.naturalHeight : image.height;
|
|
586
|
+
return { image, x: 0, y: 0, w, h };
|
|
587
|
+
}
|
|
588
|
+
function makeCanvas(w, h) {
|
|
589
|
+
if (typeof OffscreenCanvas !== "undefined")
|
|
590
|
+
return new OffscreenCanvas(w, h);
|
|
591
|
+
const c = document.createElement("canvas");
|
|
592
|
+
c.width = w;
|
|
593
|
+
c.height = h;
|
|
594
|
+
return c;
|
|
595
|
+
}
|
|
596
|
+
/** LRU cache of rasterised text (white; tinted at draw time). */
|
|
597
|
+
class TextCache {
|
|
598
|
+
entries = new Map();
|
|
599
|
+
clock = 0;
|
|
600
|
+
get(text, font, scale, evict) {
|
|
601
|
+
const key = `${scale}|${font}|${text}`;
|
|
602
|
+
const hit = this.entries.get(key);
|
|
603
|
+
if (hit) {
|
|
604
|
+
hit.used = ++this.clock;
|
|
605
|
+
return hit;
|
|
606
|
+
}
|
|
607
|
+
const measureCtx = makeCanvas(1, 1).getContext("2d");
|
|
608
|
+
if (!measureCtx)
|
|
609
|
+
throw new Error("draw.text needs a 2D canvas for rasterising text");
|
|
610
|
+
measureCtx.font = font;
|
|
611
|
+
const metrics = measureCtx.measureText(text);
|
|
612
|
+
const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
|
|
613
|
+
const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
|
|
614
|
+
const w = Math.ceil(metrics.width) + 2;
|
|
615
|
+
const h = Math.ceil(ascent + descent) + 2;
|
|
616
|
+
const canvas = makeCanvas(Math.ceil(w * scale), Math.ceil(h * scale));
|
|
617
|
+
const ctx = canvas.getContext("2d");
|
|
618
|
+
if (ctx) {
|
|
619
|
+
ctx.scale(scale, scale);
|
|
620
|
+
ctx.font = font;
|
|
621
|
+
ctx.fillStyle = "#fff";
|
|
622
|
+
ctx.textBaseline = "alphabetic";
|
|
623
|
+
ctx.fillText(text, 1, 1 + ascent);
|
|
624
|
+
}
|
|
625
|
+
const entry = { canvas, w, h, used: ++this.clock };
|
|
626
|
+
this.entries.set(key, entry);
|
|
627
|
+
if (this.entries.size > 256) {
|
|
628
|
+
// Evict the least recently used quarter.
|
|
629
|
+
const old = [...this.entries].sort((a, b) => a[1].used - b[1].used).slice(0, 64);
|
|
630
|
+
for (const [k, e] of old) {
|
|
631
|
+
this.entries.delete(k);
|
|
632
|
+
evict(e.canvas);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return entry;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
//# sourceMappingURL=draw.js.map
|