dotframe 0.1.0
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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/assets/audio/hit.mp3 +0 -0
- package/assets/audio/loop.mp3 +0 -0
- package/assets/crafter.png +0 -0
- package/assets/fonts/archivo-black.json +133 -0
- package/assets/fonts/archivo-black.png +0 -0
- package/assets/fonts/bangers.json +133 -0
- package/assets/fonts/bangers.png +0 -0
- package/assets/suzanne.glb +0 -0
- package/cli/commands/config.ts +35 -0
- package/cli/commands/doctor.ts +71 -0
- package/cli/commands/new.ts +73 -0
- package/cli/commands/play.ts +244 -0
- package/cli/commands/ship.ts +94 -0
- package/cli/commands/skills.ts +66 -0
- package/cli/commands/snap.ts +88 -0
- package/cli/lib.ts +148 -0
- package/cli/main.ts +95 -0
- package/cli/simkit.ts +152 -0
- package/cli/tsconfig.json +8 -0
- package/native/df_audio.c +266 -0
- package/native/df_native.c +605 -0
- package/native/ffi.macos.json +318 -0
- package/native/ffi.windows.json +322 -0
- package/native/third_party/dr_mp3.h +5430 -0
- package/native/third_party/stb_image.h +7988 -0
- package/native/third_party/stb_image_write.h +1724 -0
- package/native/third_party/stb_truetype.h +5079 -0
- package/package.json +47 -0
- package/scripts/build-native.sh +33 -0
- package/scripts/build-web.sh +10 -0
- package/scripts/vendor.sh +36 -0
- package/skills/assets/SKILL.md +11 -0
- package/skills/core/SKILL.md +51 -0
- package/skills/core/references/errors.md +22 -0
- package/skills/discord/SKILL.md +12 -0
- package/skills/export-web/SKILL.md +20 -0
- package/skills/game-design/SKILL.md +27 -0
- package/skills/game-design/references/sim-walkthrough.md +32 -0
- package/skills/ios/SKILL.md +19 -0
- package/skills/macos/SKILL.md +16 -0
- package/skills/netplay/SKILL.md +34 -0
- package/skills/relay/SKILL.md +18 -0
- package/src/audio.ts +19 -0
- package/src/draw2d.ts +901 -0
- package/src/ecs.ts +30 -0
- package/src/gltf.ts +82 -0
- package/src/gpu.ts +86 -0
- package/src/input.ts +215 -0
- package/src/math.ts +113 -0
- package/src/native/backend.ts +142 -0
- package/src/native/ffi.ts +42 -0
- package/src/native/library.ts +65 -0
- package/src/native/run.ts +88 -0
- package/src/physics.ts +57 -0
- package/src/platform.ts +12 -0
- package/src/raster2d.ts +348 -0
- package/src/render.ts +132 -0
- package/src/shapes.ts +37 -0
- package/src/sim.ts +56 -0
- package/src/storage.ts +6 -0
- package/src/web/run.ts +338 -0
- package/templates/_base/AGENTS.md +7 -0
- package/templates/_base/dotframe.json +15 -0
- package/templates/_base/gitignore +5 -0
- package/templates/_base/index.html +14 -0
- package/templates/_base/main.web.ts +27 -0
- package/templates/_base/package.json +12 -0
- package/templates/_base/sim.ts +31 -0
- package/templates/blank/game.ts +64 -0
- package/templates/fighter/game.ts +131 -0
- package/templates/platformer/game.ts +120 -0
- package/tools/check-audio.ts +57 -0
- package/tools/gen-library-glue.ts +83 -0
- package/tools/make-sprite.ts +92 -0
package/src/draw2d.ts
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { BufferUsage, type Color, type Draw, type RenderGpu, type Texture, VertexFormat } from "./gpu";
|
|
2
|
+
|
|
3
|
+
// Immediate-mode 2D drawing modeled on the Canvas2D API, so Canvas2D games port mechanically.
|
|
4
|
+
// Coordinates are logical pixels (top-left origin) mapped onto the whole surface.
|
|
5
|
+
// State setters replace Canvas2D property assignment: setFillStyle("#fff") for ctx.fillStyle = "#fff".
|
|
6
|
+
|
|
7
|
+
export interface Draw2D {
|
|
8
|
+
begin: () => void;
|
|
9
|
+
end: (clear: Color) => void;
|
|
10
|
+
save: () => void;
|
|
11
|
+
restore: () => void;
|
|
12
|
+
translate: (x: number, y: number) => void;
|
|
13
|
+
rotate: (radians: number) => void;
|
|
14
|
+
scale: (x: number, y: number) => void;
|
|
15
|
+
setTransform: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
16
|
+
resetTransform: () => void;
|
|
17
|
+
setFillStyle: (color: string) => void;
|
|
18
|
+
setStrokeStyle: (color: string) => void;
|
|
19
|
+
setLineWidth: (width: number) => void;
|
|
20
|
+
setGlobalAlpha: (alpha: number) => void;
|
|
21
|
+
getGlobalAlpha: () => number;
|
|
22
|
+
fillRect: (x: number, y: number, width: number, height: number) => void;
|
|
23
|
+
strokeRect: (x: number, y: number, width: number, height: number) => void;
|
|
24
|
+
beginPath: () => void;
|
|
25
|
+
moveTo: (x: number, y: number) => void;
|
|
26
|
+
lineTo: (x: number, y: number) => void;
|
|
27
|
+
rect: (x: number, y: number, width: number, height: number) => void;
|
|
28
|
+
arc: (x: number, y: number, radius: number, start: number, end: number, counterclockwise: boolean) => void;
|
|
29
|
+
ellipse: (
|
|
30
|
+
x: number,
|
|
31
|
+
y: number,
|
|
32
|
+
radiusX: number,
|
|
33
|
+
radiusY: number,
|
|
34
|
+
rotation: number,
|
|
35
|
+
start: number,
|
|
36
|
+
end: number,
|
|
37
|
+
counterclockwise: boolean,
|
|
38
|
+
) => void;
|
|
39
|
+
closePath: () => void;
|
|
40
|
+
fill: () => void;
|
|
41
|
+
stroke: () => void;
|
|
42
|
+
// drawImage drawn as a solid-color silhouette of the image's alpha.
|
|
43
|
+
drawImageTinted: (
|
|
44
|
+
image: Texture,
|
|
45
|
+
sx: number,
|
|
46
|
+
sy: number,
|
|
47
|
+
sw: number,
|
|
48
|
+
sh: number,
|
|
49
|
+
dx: number,
|
|
50
|
+
dy: number,
|
|
51
|
+
dw: number,
|
|
52
|
+
dh: number,
|
|
53
|
+
color: string,
|
|
54
|
+
) => void;
|
|
55
|
+
// Registers an SDF font baked by tools/bake-font.c under one or more CSS family names.
|
|
56
|
+
addFont: (families: string[], atlas: Texture, metricsJson: string) => void;
|
|
57
|
+
// CSS font shorthand, e.g. 'italic 900 24px "Arial Black", Impact, sans-serif'.
|
|
58
|
+
setFont: (font: string) => void;
|
|
59
|
+
setTextAlign: (align: string) => void;
|
|
60
|
+
setTextBaseline: (baseline: string) => void;
|
|
61
|
+
fillText: (text: string, x: number, y: number) => void;
|
|
62
|
+
strokeText: (text: string, x: number, y: number) => void;
|
|
63
|
+
measureText: (text: string) => TextMetrics2D;
|
|
64
|
+
drawImage: (
|
|
65
|
+
image: Texture,
|
|
66
|
+
sx: number,
|
|
67
|
+
sy: number,
|
|
68
|
+
sw: number,
|
|
69
|
+
sh: number,
|
|
70
|
+
dx: number,
|
|
71
|
+
dy: number,
|
|
72
|
+
dw: number,
|
|
73
|
+
dh: number,
|
|
74
|
+
) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TextMetrics2D {
|
|
78
|
+
width: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Glyph {
|
|
82
|
+
code: number;
|
|
83
|
+
x: number;
|
|
84
|
+
y: number;
|
|
85
|
+
width: number;
|
|
86
|
+
height: number;
|
|
87
|
+
offsetX: number;
|
|
88
|
+
offsetY: number;
|
|
89
|
+
advance: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface FontMetrics {
|
|
93
|
+
size: number;
|
|
94
|
+
ascent: number;
|
|
95
|
+
descent: number;
|
|
96
|
+
distanceRange: number;
|
|
97
|
+
glyphs: Glyph[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface Font {
|
|
101
|
+
atlas: Texture;
|
|
102
|
+
metrics: FontMetrics;
|
|
103
|
+
glyphs: Map<number, Glyph>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FontSpec {
|
|
107
|
+
size: number;
|
|
108
|
+
italic: boolean;
|
|
109
|
+
families: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Rgba {
|
|
113
|
+
r: number;
|
|
114
|
+
g: number;
|
|
115
|
+
b: number;
|
|
116
|
+
a: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface State {
|
|
120
|
+
a: number;
|
|
121
|
+
b: number;
|
|
122
|
+
c: number;
|
|
123
|
+
d: number;
|
|
124
|
+
e: number;
|
|
125
|
+
f: number;
|
|
126
|
+
fill: Rgba;
|
|
127
|
+
stroke: Rgba;
|
|
128
|
+
lineWidth: number;
|
|
129
|
+
alpha: number;
|
|
130
|
+
font: FontSpec;
|
|
131
|
+
textAlign: string;
|
|
132
|
+
textBaseline: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Batch {
|
|
136
|
+
texture: number;
|
|
137
|
+
first: number;
|
|
138
|
+
count: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const shader = `
|
|
142
|
+
struct Uniforms { size: vec4f }
|
|
143
|
+
@group(0) @binding(0) var<uniform> u: Uniforms;
|
|
144
|
+
@group(0) @binding(1) var tex: texture_2d<f32>;
|
|
145
|
+
@group(0) @binding(2) var samp: sampler;
|
|
146
|
+
|
|
147
|
+
struct VertexOut {
|
|
148
|
+
@builtin(position) position: vec4f,
|
|
149
|
+
@location(0) uv: vec2f,
|
|
150
|
+
@location(1) color: vec4f,
|
|
151
|
+
@location(2) edge: f32,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@vertex
|
|
155
|
+
fn vs_main(
|
|
156
|
+
@location(0) position: vec2f,
|
|
157
|
+
@location(1) uv: vec2f,
|
|
158
|
+
@location(2) color: vec4f,
|
|
159
|
+
@location(3) edge: f32,
|
|
160
|
+
) -> VertexOut {
|
|
161
|
+
var out: VertexOut;
|
|
162
|
+
out.position = vec4f(position.x / u.size.x * 2.0 - 1.0, 1.0 - position.y / u.size.y * 2.0, 0.0, 1.0);
|
|
163
|
+
out.uv = uv;
|
|
164
|
+
out.color = color;
|
|
165
|
+
out.edge = edge;
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// edge >= 0: signed distance field cut at that alpha. -1: texture times color. -2: color with the texture's alpha
|
|
170
|
+
// (a silhouette, like Canvas2D's source-in over an image).
|
|
171
|
+
@fragment
|
|
172
|
+
fn fs_main(in: VertexOut) -> @location(0) vec4f {
|
|
173
|
+
let sample = textureSample(tex, samp, in.uv);
|
|
174
|
+
let distance = sample.a;
|
|
175
|
+
let smoothing = max(fwidth(distance) * 0.75, 0.001);
|
|
176
|
+
let coverage = smoothstep(in.edge - smoothing, in.edge + smoothing, distance);
|
|
177
|
+
if (in.edge < -1.5) {
|
|
178
|
+
return vec4f(in.color.rgb, in.color.a * sample.a);
|
|
179
|
+
}
|
|
180
|
+
if (in.edge < 0.0) {
|
|
181
|
+
return sample * in.color;
|
|
182
|
+
}
|
|
183
|
+
return vec4f(in.color.rgb, in.color.a * coverage);
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
186
|
+
|
|
187
|
+
// Vertex layout, in 4-byte words: position (2 × f32), uv (2 × f32), color packed as unorm8x4, edge (f32).
|
|
188
|
+
const WORDS_PER_VERTEX = 6;
|
|
189
|
+
const COLOR_WORD = 4;
|
|
190
|
+
const EDGE_WORD = 5;
|
|
191
|
+
|
|
192
|
+
// Packs a color with components in [0, 1] into the unorm8x4 word the shader reads as a vec4f. Consecutive
|
|
193
|
+
// draw calls almost always share a color, so the last result is reused.
|
|
194
|
+
const lastColor = new Float64Array(5);
|
|
195
|
+
lastColor[0] = -1;
|
|
196
|
+
function packColor(r: number, g: number, b: number, a: number): number {
|
|
197
|
+
const memo = lastColor;
|
|
198
|
+
if (r === memo[0] && g === memo[1] && b === memo[2] && a === memo[3]) return memo[4];
|
|
199
|
+
const packed = computeColor(r, g, b, a);
|
|
200
|
+
memo[0] = r;
|
|
201
|
+
memo[1] = g;
|
|
202
|
+
memo[2] = b;
|
|
203
|
+
memo[3] = a;
|
|
204
|
+
memo[4] = packed;
|
|
205
|
+
return packed;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function computeColor(r: number, g: number, b: number, a: number): number {
|
|
209
|
+
const R = Math.round(Math.min(Math.max(r, 0), 1) * 255);
|
|
210
|
+
const G = Math.round(Math.min(Math.max(g, 0), 1) * 255);
|
|
211
|
+
const B = Math.round(Math.min(Math.max(b, 0), 1) * 255);
|
|
212
|
+
const A = Math.round(Math.min(Math.max(a, 0), 1) * 255);
|
|
213
|
+
return (R | (G << 8) | (B << 16) | (A << 24)) >>> 0;
|
|
214
|
+
}
|
|
215
|
+
const WHITE: Rgba = { r: 1, g: 1, b: 1, a: 1 };
|
|
216
|
+
// stb_truetype SDF: 0.5 alpha on the outline, 128/distanceRange alpha steps per atlas pixel.
|
|
217
|
+
const SDF_ON_EDGE = 128 / 255;
|
|
218
|
+
const INITIAL_VERTICES = 65536;
|
|
219
|
+
const MAX_VERTICES = 4194304;
|
|
220
|
+
const MAX_CURVE_SEGMENTS = 64;
|
|
221
|
+
|
|
222
|
+
const named: Map<string, string> = new Map([
|
|
223
|
+
["white", "#ffffff"],
|
|
224
|
+
["black", "#000000"],
|
|
225
|
+
["red", "#ff0000"],
|
|
226
|
+
["green", "#008000"],
|
|
227
|
+
["blue", "#0000ff"],
|
|
228
|
+
["yellow", "#ffff00"],
|
|
229
|
+
["orange", "#ffa500"],
|
|
230
|
+
["purple", "#800080"],
|
|
231
|
+
["gray", "#808080"],
|
|
232
|
+
["grey", "#808080"],
|
|
233
|
+
["transparent", "#00000000"],
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
function hexByte(text: string, start: number, length: number): number {
|
|
237
|
+
const digits = "0123456789abcdef";
|
|
238
|
+
let value = 0;
|
|
239
|
+
for (let i = 0; i < length; i++) value = value * 16 + digits.indexOf(text[start + i]);
|
|
240
|
+
return length === 1 ? value * 17 : value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Parses #rgb, #rgba, #rrggbb, #rrggbbaa, rgb(), rgba() and a few names.
|
|
244
|
+
export function parseColor(input: string): Rgba {
|
|
245
|
+
const text = (named.get(input.trim().toLowerCase()) ?? input).trim().toLowerCase();
|
|
246
|
+
if (text.startsWith("#")) {
|
|
247
|
+
const hex = text.slice(1);
|
|
248
|
+
const step = hex.length <= 4 ? 1 : 2;
|
|
249
|
+
const r = hexByte(hex, 0, step);
|
|
250
|
+
const g = hexByte(hex, step, step);
|
|
251
|
+
const b = hexByte(hex, step * 2, step);
|
|
252
|
+
const a = hex.length === 4 || hex.length === 8 ? hexByte(hex, step * 3, step) : 255;
|
|
253
|
+
return { r: r / 255, g: g / 255, b: b / 255, a: a / 255 };
|
|
254
|
+
}
|
|
255
|
+
const open = text.indexOf("(");
|
|
256
|
+
const close = text.lastIndexOf(")");
|
|
257
|
+
if (open > 0 && close > open) {
|
|
258
|
+
const parts = text.slice(open + 1, close).split(",");
|
|
259
|
+
const channel = (index: number): number => Number((parts[index] ?? "0").trim().split("%").join(""));
|
|
260
|
+
const alpha = parts.length > 3 ? channel(3) : 1;
|
|
261
|
+
if (text.startsWith("hsl")) {
|
|
262
|
+
// hsl(h, s%, l%) per CSS Color 4.
|
|
263
|
+
const h = (((channel(0) % 360) + 360) % 360) / 360;
|
|
264
|
+
const sat = channel(1) / 100;
|
|
265
|
+
const light = channel(2) / 100;
|
|
266
|
+
const q = light < 0.5 ? light * (1 + sat) : light + sat - light * sat;
|
|
267
|
+
const p = 2 * light - q;
|
|
268
|
+
const hue = (t: number): number => {
|
|
269
|
+
const u = t < 0 ? t + 1 : t > 1 ? t - 1 : t;
|
|
270
|
+
if (u < 1 / 6) return p + (q - p) * 6 * u;
|
|
271
|
+
if (u < 1 / 2) return q;
|
|
272
|
+
if (u < 2 / 3) return p + (q - p) * (2 / 3 - u) * 6;
|
|
273
|
+
return p;
|
|
274
|
+
};
|
|
275
|
+
return { r: hue(h + 1 / 3), g: hue(h), b: hue(h - 1 / 3), a: alpha };
|
|
276
|
+
}
|
|
277
|
+
return { r: channel(0) / 255, g: channel(1) / 255, b: channel(2) / 255, a: alpha };
|
|
278
|
+
}
|
|
279
|
+
return { r: 0, g: 0, b: 0, a: 1 };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Ear clipping over a simple polygon given as flat [x0, y0, x1, y1, ...]. Returns triangle vertex indices.
|
|
283
|
+
export function triangulate(points: number[]): number[] {
|
|
284
|
+
const count = points.length / 2;
|
|
285
|
+
const result: number[] = [];
|
|
286
|
+
if (count < 3) return result;
|
|
287
|
+
let area = 0;
|
|
288
|
+
for (let i = 0; i < count; i++) {
|
|
289
|
+
const j = (i + 1) % count;
|
|
290
|
+
area += points[i * 2] * points[j * 2 + 1] - points[j * 2] * points[i * 2 + 1];
|
|
291
|
+
}
|
|
292
|
+
const winding = area >= 0 ? 1 : -1;
|
|
293
|
+
const remaining: number[] = [];
|
|
294
|
+
for (let i = 0; i < count; i++) remaining.push(i);
|
|
295
|
+
|
|
296
|
+
const cross = (a: number, b: number, c: number): number =>
|
|
297
|
+
(points[b * 2] - points[a * 2]) * (points[c * 2 + 1] - points[a * 2 + 1]) -
|
|
298
|
+
(points[b * 2 + 1] - points[a * 2 + 1]) * (points[c * 2] - points[a * 2]);
|
|
299
|
+
const inside = (p: number, a: number, b: number, c: number): boolean =>
|
|
300
|
+
cross(a, b, p) * winding > 0 && cross(b, c, p) * winding > 0 && cross(c, a, p) * winding > 0;
|
|
301
|
+
|
|
302
|
+
let guard = 0;
|
|
303
|
+
while (remaining.length > 3 && guard < count * count) {
|
|
304
|
+
guard++;
|
|
305
|
+
let clipped = false;
|
|
306
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
307
|
+
const a = remaining[(i + remaining.length - 1) % remaining.length];
|
|
308
|
+
const b = remaining[i];
|
|
309
|
+
const c = remaining[(i + 1) % remaining.length];
|
|
310
|
+
if (cross(a, b, c) * winding <= 0) continue;
|
|
311
|
+
let blocked = false;
|
|
312
|
+
for (const p of remaining) {
|
|
313
|
+
if (p !== a && p !== b && p !== c && inside(p, a, b, c)) {
|
|
314
|
+
blocked = true;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (blocked) continue;
|
|
319
|
+
result.push(a, b, c);
|
|
320
|
+
remaining.splice(i, 1);
|
|
321
|
+
clipped = true;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
// Degenerate input: fall back to a fan over what is left.
|
|
325
|
+
if (!clipped) break;
|
|
326
|
+
}
|
|
327
|
+
for (let i = 1; i + 1 < remaining.length; i++) result.push(remaining[0], remaining[i], remaining[i + 1]);
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Parses the size, italic flag and family list out of a CSS font shorthand.
|
|
332
|
+
export function parseFont(font: string): FontSpec {
|
|
333
|
+
const pxIndex = font.indexOf("px");
|
|
334
|
+
let start = pxIndex;
|
|
335
|
+
while (start > 0 && "0123456789.".includes(font[start - 1])) start--;
|
|
336
|
+
const size = pxIndex > 0 ? Number(font.slice(start, pxIndex)) : 10;
|
|
337
|
+
const families: string[] = [];
|
|
338
|
+
for (const part of font.slice(pxIndex > 0 ? pxIndex + 2 : 0).split(",")) {
|
|
339
|
+
const name = part.trim().split('"').join("").split("'").join("");
|
|
340
|
+
if (name.length > 0) families.push(name.toLowerCase());
|
|
341
|
+
}
|
|
342
|
+
return { size, italic: font.slice(0, Math.max(start, 0)).includes("italic"), families };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function createDraw2D(gpu: RenderGpu, width: number, height: number): Draw2D {
|
|
346
|
+
const pipeline = gpu.createPipeline({
|
|
347
|
+
wgsl: shader,
|
|
348
|
+
stride: WORDS_PER_VERTEX * 4,
|
|
349
|
+
attributes: [
|
|
350
|
+
{ format: VertexFormat.Float32x2, offset: 0, location: 0 },
|
|
351
|
+
{ format: VertexFormat.Float32x2, offset: 8, location: 1 },
|
|
352
|
+
{ format: VertexFormat.Unorm8x4, offset: COLOR_WORD * 4, location: 2 },
|
|
353
|
+
{ format: VertexFormat.Float32, offset: EDGE_WORD * 4, location: 3 },
|
|
354
|
+
],
|
|
355
|
+
depth: false,
|
|
356
|
+
blend: true,
|
|
357
|
+
});
|
|
358
|
+
// Grows by doubling when a frame needs more room; the GPU buffer is recreated to match at the end of the frame.
|
|
359
|
+
let capacity = INITIAL_VERTICES;
|
|
360
|
+
let vertices = new Float32Array(capacity * WORDS_PER_VERTEX);
|
|
361
|
+
// The same memory as words, for packed colors.
|
|
362
|
+
let words = new Uint32Array(vertices.buffer);
|
|
363
|
+
let vertexBuffer = gpu.createBuffer(BufferUsage.Vertex, new Uint8Array(vertices.byteLength));
|
|
364
|
+
let gpuCapacity = capacity;
|
|
365
|
+
const grow = (needed: number): boolean => {
|
|
366
|
+
if (needed <= capacity) return true;
|
|
367
|
+
if (needed > MAX_VERTICES) return false;
|
|
368
|
+
while (capacity < needed) capacity *= 2;
|
|
369
|
+
const larger = new Float32Array(capacity * WORDS_PER_VERTEX);
|
|
370
|
+
larger.set(vertices.subarray(0, vertexCount * WORDS_PER_VERTEX));
|
|
371
|
+
vertices = larger;
|
|
372
|
+
words = new Uint32Array(larger.buffer);
|
|
373
|
+
return true;
|
|
374
|
+
};
|
|
375
|
+
const uniforms = new Float32Array([width, height, 0, 0]);
|
|
376
|
+
const uniformBuffer = gpu.createBuffer(
|
|
377
|
+
BufferUsage.Uniform,
|
|
378
|
+
new Uint8Array(uniforms.buffer, uniforms.byteOffset, uniforms.byteLength),
|
|
379
|
+
);
|
|
380
|
+
const white = gpu.createTexture(1, 1, new Uint8Array([255, 255, 255, 255]), false);
|
|
381
|
+
const bindGroups = new Map<number, number>();
|
|
382
|
+
const bindGroupFor = (texture: number): number => {
|
|
383
|
+
let group = bindGroups.get(texture);
|
|
384
|
+
if (group === undefined) {
|
|
385
|
+
group = gpu.bind(pipeline, uniformBuffer, texture);
|
|
386
|
+
bindGroups.set(texture, group);
|
|
387
|
+
}
|
|
388
|
+
return group;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const initialState = (): State => ({
|
|
392
|
+
a: 1,
|
|
393
|
+
b: 0,
|
|
394
|
+
c: 0,
|
|
395
|
+
d: 1,
|
|
396
|
+
e: 0,
|
|
397
|
+
f: 0,
|
|
398
|
+
fill: { r: 0, g: 0, b: 0, a: 1 },
|
|
399
|
+
stroke: { r: 0, g: 0, b: 0, a: 1 },
|
|
400
|
+
lineWidth: 1,
|
|
401
|
+
alpha: 1,
|
|
402
|
+
font: { size: 10, italic: false, families: ["sans-serif"] },
|
|
403
|
+
textAlign: "start",
|
|
404
|
+
textBaseline: "alphabetic",
|
|
405
|
+
});
|
|
406
|
+
const fonts = new Map<string, Font>();
|
|
407
|
+
let defaultFont: Font | null = null;
|
|
408
|
+
let state = initialState();
|
|
409
|
+
const stack: State[] = [];
|
|
410
|
+
let vertexCount = 0;
|
|
411
|
+
const batches: Batch[] = [];
|
|
412
|
+
// Current path, in path (untransformed) space: every point of every subpath in one flat x,y buffer. Points
|
|
413
|
+
// are only ever appended to the last subpath, so subpath s spans [starts[s], starts[s + 1]) and the last one
|
|
414
|
+
// ends at pathLength. A typed buffer keeps point reads and writes inline in compiled builds.
|
|
415
|
+
let pathData = new Float64Array(1024);
|
|
416
|
+
let pathLength = 0;
|
|
417
|
+
const starts: number[] = [];
|
|
418
|
+
const closed: boolean[] = [];
|
|
419
|
+
// A subpath built only from one arc, ellipse or rect is convex and fills as a fan, skipping ear clipping.
|
|
420
|
+
const convex: boolean[] = [];
|
|
421
|
+
let subpathCount = 0;
|
|
422
|
+
const startSubpath = (isClosed: boolean, isConvex: boolean): void => {
|
|
423
|
+
if (subpathCount === starts.length) {
|
|
424
|
+
starts.push(0);
|
|
425
|
+
closed.push(false);
|
|
426
|
+
convex.push(false);
|
|
427
|
+
}
|
|
428
|
+
starts[subpathCount] = pathLength;
|
|
429
|
+
closed[subpathCount] = isClosed;
|
|
430
|
+
convex[subpathCount] = isConvex;
|
|
431
|
+
subpathCount++;
|
|
432
|
+
};
|
|
433
|
+
// Room for `floats` more values in the path buffer.
|
|
434
|
+
const reservePath = (floats: number): Float64Array => {
|
|
435
|
+
if (pathLength + floats > pathData.length) {
|
|
436
|
+
let size = pathData.length * 2;
|
|
437
|
+
while (size < pathLength + floats) size *= 2;
|
|
438
|
+
const larger = new Float64Array(size);
|
|
439
|
+
larger.set(pathData.subarray(0, pathLength));
|
|
440
|
+
pathData = larger;
|
|
441
|
+
}
|
|
442
|
+
return pathData;
|
|
443
|
+
};
|
|
444
|
+
const pushPoint = (x: number, y: number): void => {
|
|
445
|
+
const data = reservePath(2);
|
|
446
|
+
data[pathLength] = x;
|
|
447
|
+
data[pathLength + 1] = y;
|
|
448
|
+
pathLength = pathLength + 2;
|
|
449
|
+
};
|
|
450
|
+
const subpathStart = (s: number): number => starts[s];
|
|
451
|
+
const subpathEnd = (s: number): number => (s + 1 < subpathCount ? starts[s + 1] : pathLength);
|
|
452
|
+
|
|
453
|
+
const vertex = (x: number, y: number, u: number, v: number, color: Rgba, edge: number): void => {
|
|
454
|
+
if (vertexCount >= capacity) return;
|
|
455
|
+
// Local aliases: each access to a captured binding costs an indirection plus a retain in compiled builds.
|
|
456
|
+
const out = vertices;
|
|
457
|
+
const t = state;
|
|
458
|
+
const o = vertexCount * WORDS_PER_VERTEX;
|
|
459
|
+
out[o] = t.a * x + t.c * y + t.e;
|
|
460
|
+
out[o + 1] = t.b * x + t.d * y + t.f;
|
|
461
|
+
out[o + 2] = u;
|
|
462
|
+
out[o + 3] = v;
|
|
463
|
+
words[o + COLOR_WORD] = packColor(color.r, color.g, color.b, color.a * t.alpha);
|
|
464
|
+
out[o + EDGE_WORD] = edge;
|
|
465
|
+
vertexCount = vertexCount + 1;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Writes two triangles for the quad (x0,y0)-(x1,y1)-(x2,y2)-(x3,y3) with texture corners (u0,v0)-(u1,v1),
|
|
469
|
+
// transforming each corner once. The caller has reserved 6 vertices with useTexture.
|
|
470
|
+
const emitQuad = (
|
|
471
|
+
x0: number,
|
|
472
|
+
y0: number,
|
|
473
|
+
x1: number,
|
|
474
|
+
y1: number,
|
|
475
|
+
x2: number,
|
|
476
|
+
y2: number,
|
|
477
|
+
x3: number,
|
|
478
|
+
y3: number,
|
|
479
|
+
u0: number,
|
|
480
|
+
v0: number,
|
|
481
|
+
u1: number,
|
|
482
|
+
v1: number,
|
|
483
|
+
color: Rgba,
|
|
484
|
+
edge: number,
|
|
485
|
+
): void => {
|
|
486
|
+
if (vertexCount + 6 > capacity) return;
|
|
487
|
+
const out = vertices;
|
|
488
|
+
const t = state;
|
|
489
|
+
const ax = t.a * x0 + t.c * y0 + t.e;
|
|
490
|
+
const ay = t.b * x0 + t.d * y0 + t.f;
|
|
491
|
+
const bx = t.a * x1 + t.c * y1 + t.e;
|
|
492
|
+
const by = t.b * x1 + t.d * y1 + t.f;
|
|
493
|
+
const cx = t.a * x2 + t.c * y2 + t.e;
|
|
494
|
+
const cy = t.b * x2 + t.d * y2 + t.f;
|
|
495
|
+
const dx = t.a * x3 + t.c * y3 + t.e;
|
|
496
|
+
const dy = t.b * x3 + t.d * y3 + t.f;
|
|
497
|
+
const packed = words;
|
|
498
|
+
const rgba = packColor(color.r, color.g, color.b, color.a * t.alpha);
|
|
499
|
+
let o = vertexCount * WORDS_PER_VERTEX;
|
|
500
|
+
// Corner order: a b c, a c d.
|
|
501
|
+
for (let k = 0; k < 6; k++) {
|
|
502
|
+
const corner = k === 0 || k === 3 ? 0 : k === 1 ? 1 : k === 2 || k === 4 ? 2 : 3;
|
|
503
|
+
out[o] = corner === 0 ? ax : corner === 1 ? bx : corner === 2 ? cx : dx;
|
|
504
|
+
out[o + 1] = corner === 0 ? ay : corner === 1 ? by : corner === 2 ? cy : dy;
|
|
505
|
+
out[o + 2] = corner === 0 || corner === 3 ? u0 : u1;
|
|
506
|
+
out[o + 3] = corner === 0 || corner === 1 ? v0 : v1;
|
|
507
|
+
packed[o + COLOR_WORD] = rgba;
|
|
508
|
+
out[o + EDGE_WORD] = edge;
|
|
509
|
+
o += WORDS_PER_VERTEX;
|
|
510
|
+
}
|
|
511
|
+
vertexCount = vertexCount + 6;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// The batch being filled lives in plain numbers and joins `batches` only when the texture changes: reading
|
|
515
|
+
// the array's last element back on every draw call costs an allocation in compiled builds.
|
|
516
|
+
let openTexture = -1;
|
|
517
|
+
let openFirst = 0;
|
|
518
|
+
let openCount = 0;
|
|
519
|
+
const closeBatch = (): void => {
|
|
520
|
+
if (openCount > 0) batches.push({ texture: openTexture, first: openFirst, count: openCount });
|
|
521
|
+
openCount = 0;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// Reserves room for whole triangles on the given texture, starting a new batch when it changes.
|
|
525
|
+
const useTexture = (texture: number, triangleVertices: number): boolean => {
|
|
526
|
+
if (!grow(vertexCount + triangleVertices)) return false;
|
|
527
|
+
if (openCount === 0 || texture !== openTexture) {
|
|
528
|
+
closeBatch();
|
|
529
|
+
openTexture = texture;
|
|
530
|
+
openFirst = vertexCount;
|
|
531
|
+
}
|
|
532
|
+
openCount = openCount + triangleVertices;
|
|
533
|
+
return true;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const quad = (
|
|
537
|
+
x0: number,
|
|
538
|
+
y0: number,
|
|
539
|
+
x1: number,
|
|
540
|
+
y1: number,
|
|
541
|
+
x2: number,
|
|
542
|
+
y2: number,
|
|
543
|
+
x3: number,
|
|
544
|
+
y3: number,
|
|
545
|
+
color: Rgba,
|
|
546
|
+
): void => {
|
|
547
|
+
if (!useTexture(white.id, 6)) return;
|
|
548
|
+
emitQuad(x0, y0, x1, y1, x2, y2, x3, y3, 0, 0, 0, 0, color, -1);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const strokeSegment = (ax: number, ay: number, bx: number, by: number): void => {
|
|
552
|
+
const dx = bx - ax;
|
|
553
|
+
const dy = by - ay;
|
|
554
|
+
const length = Math.hypot(dx, dy);
|
|
555
|
+
if (length === 0) return;
|
|
556
|
+
const half = state.lineWidth / 2;
|
|
557
|
+
const nx = (-dy / length) * half;
|
|
558
|
+
const ny = (dx / length) * half;
|
|
559
|
+
quad(ax + nx, ay + ny, bx + nx, by + ny, bx - nx, by - ny, ax - nx, ay - ny, state.stroke);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const ensureSubpath = (): void => {
|
|
563
|
+
if (subpathCount === 0) startSubpath(false, true);
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const arcPoints = (
|
|
567
|
+
x: number,
|
|
568
|
+
y: number,
|
|
569
|
+
radiusX: number,
|
|
570
|
+
radiusY: number,
|
|
571
|
+
rotation: number,
|
|
572
|
+
start: number,
|
|
573
|
+
end: number,
|
|
574
|
+
counterclockwise: boolean,
|
|
575
|
+
): void => {
|
|
576
|
+
let sweep = end - start;
|
|
577
|
+
const full = Math.PI * 2;
|
|
578
|
+
if (!counterclockwise && sweep < 0) sweep = (sweep % full) + full;
|
|
579
|
+
if (counterclockwise && sweep > 0) sweep = (sweep % full) - full;
|
|
580
|
+
if (Math.abs(end - start) >= full) sweep = counterclockwise ? -full : full;
|
|
581
|
+
// Segment count follows the on-screen radius, as Canvas2D does: small circles stay cheap, large ones smooth.
|
|
582
|
+
const screenRadius = Math.max(radiusX, radiusY) * Math.sqrt(Math.abs(state.a * state.d - state.b * state.c));
|
|
583
|
+
const segments = Math.min(MAX_CURVE_SEGMENTS, Math.max(8, Math.ceil(screenRadius * 1.5)));
|
|
584
|
+
const steps = Math.max(2, Math.ceil((Math.abs(sweep) / full) * segments));
|
|
585
|
+
const cos = Math.cos(rotation);
|
|
586
|
+
const sin = Math.sin(rotation);
|
|
587
|
+
ensureSubpath();
|
|
588
|
+
// Arcs appended to existing points (rounded shapes, pie slices) may be concave.
|
|
589
|
+
if (pathLength > starts[subpathCount - 1]) convex[subpathCount - 1] = false;
|
|
590
|
+
const data = reservePath((steps + 1) * 2);
|
|
591
|
+
let o = pathLength;
|
|
592
|
+
for (let i = 0; i <= steps; i++) {
|
|
593
|
+
const angle = start + (sweep * i) / steps;
|
|
594
|
+
const px = Math.cos(angle) * radiusX;
|
|
595
|
+
const py = Math.sin(angle) * radiusY;
|
|
596
|
+
data[o] = x + px * cos - py * sin;
|
|
597
|
+
data[o + 1] = y + px * sin + py * cos;
|
|
598
|
+
o += 2;
|
|
599
|
+
}
|
|
600
|
+
pathLength = o;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const resolveFont = (): Font | null => {
|
|
604
|
+
for (const family of state.font.families) {
|
|
605
|
+
const font = fonts.get(family);
|
|
606
|
+
if (font) return font;
|
|
607
|
+
}
|
|
608
|
+
return defaultFont;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const textWidth = (font: Font, text: string, scale: number): number => {
|
|
612
|
+
let width = 0;
|
|
613
|
+
for (let i = 0; i < text.length; i++) width += (font.glyphs.get(text.charCodeAt(i))?.advance ?? 0) * scale;
|
|
614
|
+
return width;
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const drawText = (text: string, x: number, y: number, color: Rgba, outline: number): void => {
|
|
618
|
+
const font = resolveFont();
|
|
619
|
+
if (!font) return;
|
|
620
|
+
const scale = state.font.size / font.metrics.size;
|
|
621
|
+
const width = textWidth(font, text, scale);
|
|
622
|
+
let penX = x;
|
|
623
|
+
if (state.textAlign === "center") penX -= width / 2;
|
|
624
|
+
else if (state.textAlign === "right" || state.textAlign === "end") penX -= width;
|
|
625
|
+
let baseline = y;
|
|
626
|
+
if (state.textBaseline === "middle") baseline += ((font.metrics.ascent + font.metrics.descent) / 2) * scale;
|
|
627
|
+
else if (state.textBaseline === "top" || state.textBaseline === "hanging") baseline += font.metrics.ascent * scale;
|
|
628
|
+
else if (state.textBaseline === "bottom" || state.textBaseline === "ideographic") baseline += font.metrics.descent * scale;
|
|
629
|
+
const stepsPerPixel = 128 / font.metrics.distanceRange / 255;
|
|
630
|
+
const edge = Math.max(0.02, SDF_ON_EDGE - (outline / scale) * stepsPerPixel);
|
|
631
|
+
const skew = state.font.italic ? 0.2 : 0;
|
|
632
|
+
const atlasWidth = font.atlas.width;
|
|
633
|
+
const atlasHeight = font.atlas.height;
|
|
634
|
+
for (let i = 0; i < text.length; i++) {
|
|
635
|
+
const glyph = font.glyphs.get(text.charCodeAt(i));
|
|
636
|
+
if (!glyph) continue;
|
|
637
|
+
if (glyph.width > 0 && useTexture(font.atlas.id, 6)) {
|
|
638
|
+
const x0 = penX + glyph.offsetX * scale;
|
|
639
|
+
const y0 = baseline + glyph.offsetY * scale;
|
|
640
|
+
const x1 = x0 + glyph.width * scale;
|
|
641
|
+
const y1 = y0 + glyph.height * scale;
|
|
642
|
+
const u0 = glyph.x / atlasWidth;
|
|
643
|
+
const v0 = glyph.y / atlasHeight;
|
|
644
|
+
const u1 = (glyph.x + glyph.width) / atlasWidth;
|
|
645
|
+
const v1 = (glyph.y + glyph.height) / atlasHeight;
|
|
646
|
+
const top = (baseline - y0) * skew;
|
|
647
|
+
const bottom = (baseline - y1) * skew;
|
|
648
|
+
emitQuad(x0 + top, y0, x1 + top, y0, x1 + bottom, y1, x0 + bottom, y1, u0, v0, u1, v1, color, edge);
|
|
649
|
+
}
|
|
650
|
+
penX += glyph.advance * scale;
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
begin: (): void => {
|
|
656
|
+
vertexCount = 0;
|
|
657
|
+
batches.length = 0;
|
|
658
|
+
openCount = 0;
|
|
659
|
+
state = initialState();
|
|
660
|
+
stack.length = 0;
|
|
661
|
+
},
|
|
662
|
+
end: (clear: Color): void => {
|
|
663
|
+
if (gpuCapacity < capacity) {
|
|
664
|
+
gpu.destroyBuffer(vertexBuffer);
|
|
665
|
+
vertexBuffer = gpu.createBuffer(BufferUsage.Vertex, new Uint8Array(capacity * WORDS_PER_VERTEX * 4));
|
|
666
|
+
gpuCapacity = capacity;
|
|
667
|
+
}
|
|
668
|
+
closeBatch();
|
|
669
|
+
gpu.writeBuffer(vertexBuffer, new Uint8Array(vertices.buffer, 0, vertexCount * WORDS_PER_VERTEX * 4));
|
|
670
|
+
const draws: Draw[] = [];
|
|
671
|
+
for (const batch of batches) {
|
|
672
|
+
draws.push({
|
|
673
|
+
pipeline,
|
|
674
|
+
bindGroup: bindGroupFor(batch.texture),
|
|
675
|
+
vertexBuffer,
|
|
676
|
+
indexBuffer: -1,
|
|
677
|
+
first: batch.first,
|
|
678
|
+
count: batch.count,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
gpu.frame(clear, draws);
|
|
682
|
+
},
|
|
683
|
+
save: (): void => {
|
|
684
|
+
stack.push({ ...state });
|
|
685
|
+
},
|
|
686
|
+
restore: (): void => {
|
|
687
|
+
const previous = stack.pop();
|
|
688
|
+
if (previous) state = previous;
|
|
689
|
+
},
|
|
690
|
+
translate: (x: number, y: number): void => {
|
|
691
|
+
state.e += state.a * x + state.c * y;
|
|
692
|
+
state.f += state.b * x + state.d * y;
|
|
693
|
+
},
|
|
694
|
+
rotate: (radians: number): void => {
|
|
695
|
+
const cos = Math.cos(radians);
|
|
696
|
+
const sin = Math.sin(radians);
|
|
697
|
+
const a = state.a * cos + state.c * sin;
|
|
698
|
+
const b = state.b * cos + state.d * sin;
|
|
699
|
+
const c = state.c * cos - state.a * sin;
|
|
700
|
+
const d = state.d * cos - state.b * sin;
|
|
701
|
+
state.a = a;
|
|
702
|
+
state.b = b;
|
|
703
|
+
state.c = c;
|
|
704
|
+
state.d = d;
|
|
705
|
+
},
|
|
706
|
+
scale: (x: number, y: number): void => {
|
|
707
|
+
state.a *= x;
|
|
708
|
+
state.b *= x;
|
|
709
|
+
state.c *= y;
|
|
710
|
+
state.d *= y;
|
|
711
|
+
},
|
|
712
|
+
setTransform: (a: number, b: number, c: number, d: number, e: number, f: number): void => {
|
|
713
|
+
state.a = a;
|
|
714
|
+
state.b = b;
|
|
715
|
+
state.c = c;
|
|
716
|
+
state.d = d;
|
|
717
|
+
state.e = e;
|
|
718
|
+
state.f = f;
|
|
719
|
+
},
|
|
720
|
+
resetTransform: (): void => {
|
|
721
|
+
state.a = 1;
|
|
722
|
+
state.b = 0;
|
|
723
|
+
state.c = 0;
|
|
724
|
+
state.d = 1;
|
|
725
|
+
state.e = 0;
|
|
726
|
+
state.f = 0;
|
|
727
|
+
},
|
|
728
|
+
setFillStyle: (color: string): void => {
|
|
729
|
+
state.fill = parseColor(color);
|
|
730
|
+
},
|
|
731
|
+
setStrokeStyle: (color: string): void => {
|
|
732
|
+
state.stroke = parseColor(color);
|
|
733
|
+
},
|
|
734
|
+
setLineWidth: (lineWidth: number): void => {
|
|
735
|
+
state.lineWidth = lineWidth;
|
|
736
|
+
},
|
|
737
|
+
setGlobalAlpha: (alpha: number): void => {
|
|
738
|
+
state.alpha = alpha;
|
|
739
|
+
},
|
|
740
|
+
getGlobalAlpha: (): number => state.alpha,
|
|
741
|
+
fillRect: (x: number, y: number, w: number, h: number): void => {
|
|
742
|
+
quad(x, y, x + w, y, x + w, y + h, x, y + h, state.fill);
|
|
743
|
+
},
|
|
744
|
+
strokeRect: (x: number, y: number, w: number, h: number): void => {
|
|
745
|
+
strokeSegment(x, y, x + w, y);
|
|
746
|
+
strokeSegment(x + w, y, x + w, y + h);
|
|
747
|
+
strokeSegment(x + w, y + h, x, y + h);
|
|
748
|
+
strokeSegment(x, y + h, x, y);
|
|
749
|
+
},
|
|
750
|
+
beginPath: (): void => {
|
|
751
|
+
subpathCount = 0;
|
|
752
|
+
pathLength = 0;
|
|
753
|
+
},
|
|
754
|
+
moveTo: (x: number, y: number): void => {
|
|
755
|
+
startSubpath(false, false);
|
|
756
|
+
pushPoint(x, y);
|
|
757
|
+
},
|
|
758
|
+
lineTo: (x: number, y: number): void => {
|
|
759
|
+
ensureSubpath();
|
|
760
|
+
pushPoint(x, y);
|
|
761
|
+
convex[subpathCount - 1] = false;
|
|
762
|
+
},
|
|
763
|
+
rect: (x: number, y: number, w: number, h: number): void => {
|
|
764
|
+
startSubpath(true, true);
|
|
765
|
+
pushPoint(x, y);
|
|
766
|
+
pushPoint(x + w, y);
|
|
767
|
+
pushPoint(x + w, y + h);
|
|
768
|
+
pushPoint(x, y + h);
|
|
769
|
+
},
|
|
770
|
+
arc: (x: number, y: number, radius: number, start: number, end: number, counterclockwise: boolean): void => {
|
|
771
|
+
arcPoints(x, y, radius, radius, 0, start, end, counterclockwise);
|
|
772
|
+
},
|
|
773
|
+
ellipse: (
|
|
774
|
+
x: number,
|
|
775
|
+
y: number,
|
|
776
|
+
radiusX: number,
|
|
777
|
+
radiusY: number,
|
|
778
|
+
rotation: number,
|
|
779
|
+
start: number,
|
|
780
|
+
end: number,
|
|
781
|
+
counterclockwise: boolean,
|
|
782
|
+
): void => {
|
|
783
|
+
arcPoints(x, y, radiusX, radiusY, rotation, start, end, counterclockwise);
|
|
784
|
+
},
|
|
785
|
+
closePath: (): void => {
|
|
786
|
+
if (subpathCount > 0) closed[subpathCount - 1] = true;
|
|
787
|
+
},
|
|
788
|
+
fill: (): void => {
|
|
789
|
+
for (let s = 0; s < subpathCount; s++) {
|
|
790
|
+
const points = pathData;
|
|
791
|
+
const base = subpathStart(s);
|
|
792
|
+
const count = (subpathEnd(s) - base) / 2;
|
|
793
|
+
if (convex[s] && count >= 3) {
|
|
794
|
+
const total = (count - 2) * 3;
|
|
795
|
+
if (!useTexture(white.id, total) || vertexCount + total > capacity) return;
|
|
796
|
+
const out = vertices;
|
|
797
|
+
const t = state;
|
|
798
|
+
const fill = t.fill;
|
|
799
|
+
const packed = words;
|
|
800
|
+
const rgba = packColor(fill.r, fill.g, fill.b, fill.a * t.alpha);
|
|
801
|
+
const fx = t.a * points[base] + t.c * points[base + 1] + t.e;
|
|
802
|
+
const fy = t.b * points[base] + t.d * points[base + 1] + t.f;
|
|
803
|
+
let px = t.a * points[base + 2] + t.c * points[base + 3] + t.e;
|
|
804
|
+
let py = t.b * points[base + 2] + t.d * points[base + 3] + t.f;
|
|
805
|
+
let o = vertexCount * WORDS_PER_VERTEX;
|
|
806
|
+
for (let i = 1; i + 1 < count; i++) {
|
|
807
|
+
const qx = t.a * points[base + i * 2 + 2] + t.c * points[base + i * 2 + 3] + t.e;
|
|
808
|
+
const qy = t.b * points[base + i * 2 + 2] + t.d * points[base + i * 2 + 3] + t.f;
|
|
809
|
+
for (let k = 0; k < 3; k++) {
|
|
810
|
+
out[o] = k === 0 ? fx : k === 1 ? px : qx;
|
|
811
|
+
out[o + 1] = k === 0 ? fy : k === 1 ? py : qy;
|
|
812
|
+
out[o + 2] = 0;
|
|
813
|
+
out[o + 3] = 0;
|
|
814
|
+
packed[o + COLOR_WORD] = rgba;
|
|
815
|
+
out[o + EDGE_WORD] = -1;
|
|
816
|
+
o += WORDS_PER_VERTEX;
|
|
817
|
+
}
|
|
818
|
+
px = qx;
|
|
819
|
+
py = qy;
|
|
820
|
+
}
|
|
821
|
+
vertexCount = vertexCount + total;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
// Concave subpaths are rare; ear clipping works on a plain copy of their points.
|
|
825
|
+
const flat: number[] = [];
|
|
826
|
+
for (let i = 0; i < count * 2; i++) flat.push(points[base + i]);
|
|
827
|
+
const indices = triangulate(flat);
|
|
828
|
+
if (!useTexture(white.id, indices.length)) return;
|
|
829
|
+
for (const index of indices) vertex(flat[index * 2], flat[index * 2 + 1], 0, 0, state.fill, -1);
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
stroke: (): void => {
|
|
833
|
+
for (let s = 0; s < subpathCount; s++) {
|
|
834
|
+
const points = pathData;
|
|
835
|
+
const base = subpathStart(s);
|
|
836
|
+
const count = (subpathEnd(s) - base) / 2;
|
|
837
|
+
for (let i = 0; i + 1 < count; i++) {
|
|
838
|
+
const o = base + i * 2;
|
|
839
|
+
strokeSegment(points[o], points[o + 1], points[o + 2], points[o + 3]);
|
|
840
|
+
}
|
|
841
|
+
if (closed[s] && count > 2) strokeSegment(points[base + count * 2 - 2], points[base + count * 2 - 1], points[base], points[base + 1]);
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
drawImageTinted: (
|
|
845
|
+
image: Texture,
|
|
846
|
+
sx: number,
|
|
847
|
+
sy: number,
|
|
848
|
+
sw: number,
|
|
849
|
+
sh: number,
|
|
850
|
+
dx: number,
|
|
851
|
+
dy: number,
|
|
852
|
+
dw: number,
|
|
853
|
+
dh: number,
|
|
854
|
+
color: string,
|
|
855
|
+
): void => {
|
|
856
|
+
if (!useTexture(image.id, 6)) return;
|
|
857
|
+
emitQuad(dx, dy, dx + dw, dy, dx + dw, dy + dh, dx, dy + dh, sx / image.width, sy / image.height, (sx + sw) / image.width, (sy + sh) / image.height, parseColor(color), -2);
|
|
858
|
+
},
|
|
859
|
+
addFont: (families: string[], atlas: Texture, metricsJson: string): void => {
|
|
860
|
+
const metrics = JSON.parse(metricsJson) as FontMetrics;
|
|
861
|
+
const glyphs = new Map<number, Glyph>();
|
|
862
|
+
for (const glyph of metrics.glyphs) glyphs.set(glyph.code, glyph);
|
|
863
|
+
const font: Font = { atlas, metrics, glyphs };
|
|
864
|
+
for (const family of families) fonts.set(family.toLowerCase(), font);
|
|
865
|
+
if (!defaultFont) defaultFont = font;
|
|
866
|
+
},
|
|
867
|
+
setFont: (font: string): void => {
|
|
868
|
+
state.font = parseFont(font);
|
|
869
|
+
},
|
|
870
|
+
setTextAlign: (align: string): void => {
|
|
871
|
+
state.textAlign = align;
|
|
872
|
+
},
|
|
873
|
+
setTextBaseline: (baseline: string): void => {
|
|
874
|
+
state.textBaseline = baseline;
|
|
875
|
+
},
|
|
876
|
+
fillText: (text: string, x: number, y: number): void => drawText(text, x, y, state.fill, 0),
|
|
877
|
+
strokeText: (text: string, x: number, y: number): void => drawText(text, x, y, state.stroke, state.lineWidth / 2),
|
|
878
|
+
measureText: (text: string): TextMetrics2D => {
|
|
879
|
+
const font = resolveFont();
|
|
880
|
+
return { width: font ? textWidth(font, text, state.font.size / font.metrics.size) : 0 };
|
|
881
|
+
},
|
|
882
|
+
drawImage: (
|
|
883
|
+
image: Texture,
|
|
884
|
+
sx: number,
|
|
885
|
+
sy: number,
|
|
886
|
+
sw: number,
|
|
887
|
+
sh: number,
|
|
888
|
+
dx: number,
|
|
889
|
+
dy: number,
|
|
890
|
+
dw: number,
|
|
891
|
+
dh: number,
|
|
892
|
+
): void => {
|
|
893
|
+
if (!useTexture(image.id, 6)) return;
|
|
894
|
+
const u0 = sx / image.width;
|
|
895
|
+
const v0 = sy / image.height;
|
|
896
|
+
const u1 = (sx + sw) / image.width;
|
|
897
|
+
const v1 = (sy + sh) / image.height;
|
|
898
|
+
emitQuad(dx, dy, dx + dw, dy, dx + dw, dy + dh, dx, dy + dh, u0, v0, u1, v1, WHITE, -1);
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
}
|