plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
93abedf
— Peter John
2026-11-02T17:27:08+05:30
plum_bridge: SDF glyph atlas text through the normal batch; text_sizes fixture
plum-game/web-test/fixtures/text_sizes.plum
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Text at sizes 8, 16, 48 and 128, then the same line through a zoomed camera.
|
|
2
|
+
# A counter and a moving bar keep the frame changing. The text comes from the SDF
|
|
3
|
+
# glyph atlas, so every size and the zoomed copy must stay crisp.
|
|
4
|
+
module text_sizes
|
|
5
|
+
import std/Str
|
|
6
|
+
import std/Int
|
|
7
|
+
import std/Float
|
|
8
|
+
import std/Vec2
|
|
9
|
+
import std/Color
|
|
10
|
+
import std/Game
|
|
11
|
+
|
|
12
|
+
record Sizes(t: Int)
|
|
13
|
+
|
|
14
|
+
export fun init() -> Sizes =
|
|
15
|
+
Sizes(t: 0)
|
|
16
|
+
|
|
17
|
+
export fun update(g: Sizes, dt: Float) =
|
|
18
|
+
g.t = g.t + 1
|
|
19
|
+
|
|
20
|
+
export fun draw(g: Sizes) =
|
|
21
|
+
clear(Color.rgb(0.08, 0.09, 0.14))
|
|
22
|
+
drawText("Size 8: The quick brown fox jumps over the lazy dog 0123456789", 10.0, 6.0, 8.0, Color.White)
|
|
23
|
+
drawText("Size 16: The quick brown fox jumps over the lazy dog", 10.0, 24.0, 16.0, Color.White)
|
|
24
|
+
drawText("Size 48: Quick brown fox", 10.0, 52.0, 48.0, Color.Yellow)
|
|
25
|
+
drawText("Size 128 Wg", 10.0, 112.0, 128.0, Color.rgb(0.5, 0.9, 1.0))
|
|
26
|
+
drawText("Latin-1: café naïve über ½ ©", 10.0, 250.0, 24.0, Color.White)
|
|
27
|
+
drawText("frame {g.t}", 700.0, 8.0, 16.0, Color.Green)
|
|
28
|
+
drawRect(Float(g.t % 400), 300.0, 60.0, 4.0, Color.Red)
|
|
29
|
+
setCamera(0.0, 0.0, 3.0)
|
|
30
|
+
drawText("Zoomed x3: size 16", 10.0, 112.0, 16.0, Color.White)
|
|
31
|
+
drawText("Zoomed x3: size 8", 10.0, 134.0, 8.0, Color.White)
|
|
32
|
+
resetCamera()
|
plum-game/web/plum_bridge.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// written into one CPU vertex/index array (camera applied on the CPU), flushed
|
|
4
4
|
// with one buffer upload per frame. A draw call binds up to 16 textures and each
|
|
5
5
|
// vertex carries the slot of its texture, so the draw only ends when a 17th
|
|
6
|
-
// texture is needed. Untextured shapes sample a 1x1 white texture.
|
|
6
|
+
// texture is needed. Untextured shapes sample a 1x1 white texture. Text is drawn
|
|
7
|
+
// as glyph quads from one signed-distance-field atlas (see makeGlyphAtlas). The
|
|
8
|
+
// backend is WebGPU where the browser has it; there is no fallback (a page
|
|
7
|
-
//
|
|
9
|
+
// without navigator.gpu says so).
|
|
8
10
|
//
|
|
9
11
|
// Each std/Game draw extern appends vertices to the batcher directly.
|
|
10
12
|
// Plum's `Str` is a GC struct JS can't read: it is read through the module's
|
|
@@ -228,11 +230,21 @@
|
|
|
228
230
|
// --- batcher ----------------------------------------------------------------------
|
|
229
231
|
|
|
230
232
|
// One vertex is 24 bytes: x, y (clip space), u, v (f32), rgba (u8x4) and a
|
|
231
|
-
// u32 holding the texture slot (bits 0-7)
|
|
233
|
+
// u32 holding the texture slot (bits 0-7), the linear-filter flag (bit 8) and the
|
|
234
|
+
// SDF flag (bit 9).
|
|
232
235
|
const VERT_WORDS = 6;
|
|
233
236
|
const VERT_BYTES = 24;
|
|
234
237
|
const SLOTS = 16;
|
|
235
238
|
const SMOOTH_BIT = 256;
|
|
239
|
+
// Glyph atlas: glyphs are rasterised at GLYPH_BASE px with GLYPH_SPREAD px of
|
|
240
|
+
// distance field around them, into an ATLAS_W x ATLAS_H rgba texture (the
|
|
241
|
+
// distance is the alpha channel, 0.5 on the outline).
|
|
242
|
+
const GLYPH_BASE = 48;
|
|
243
|
+
const GLYPH_SPREAD = 6;
|
|
244
|
+
const GLYPH_PAD = GLYPH_SPREAD;
|
|
245
|
+
const ATLAS_W = 2048;
|
|
246
|
+
const ATLAS_H = 1024;
|
|
247
|
+
const SDF_BIT = 512; // the texture is the glyph atlas: alpha holds a distance field
|
|
236
248
|
const MAX_SEGMENTS = 96;
|
|
237
249
|
|
|
238
250
|
function clamp01(v) {
|
|
@@ -261,8 +273,8 @@
|
|
|
261
273
|
return t;
|
|
262
274
|
}
|
|
263
275
|
|
|
264
|
-
// env.
|
|
276
|
+
// env.glyph(codePoint) -> { adv, w, h, u0, v0, u1, v1, tex } (sizes in atlas
|
|
265
|
-
//
|
|
277
|
+
// pixels at GLYPH_BASE; w = 0 for a blank glyph), or null; env.atlas is its texture.
|
|
266
278
|
function makeBatcher(env) {
|
|
267
279
|
let vcap = 1 << 14;
|
|
268
280
|
let icap = 1 << 15;
|
|
@@ -325,7 +337,7 @@
|
|
|
325
337
|
t.slot = cur.n;
|
|
326
338
|
cur.slots[cur.n++] = t;
|
|
327
339
|
}
|
|
328
|
-
slotBits = t.slot | (t.smooth ? SMOOTH_BIT : 0);
|
|
340
|
+
slotBits = t.slot | (t.smooth ? SMOOTH_BIT : 0) | (t.sdf ? SDF_BIT : 0);
|
|
329
341
|
}
|
|
330
342
|
|
|
331
343
|
function vert(x, y, u, v, c) {
|
|
@@ -434,19 +446,23 @@
|
|
|
434
446
|
}
|
|
435
447
|
cur.count += n * 3;
|
|
436
448
|
},
|
|
437
|
-
// `x, y` is the text's top-left.
|
|
449
|
+
// `x, y` is the text's top-left. Each glyph is a quad of the SDF atlas scaled
|
|
438
|
-
// size
|
|
450
|
+
// by size / GLYPH_BASE, so the text stays sharp at any size, zoom or dpr.
|
|
439
451
|
drawText(str, x, y, size, r, g, b, a) {
|
|
440
452
|
if (!str) return;
|
|
453
|
+
const k = size / GLYPH_BASE;
|
|
441
|
-
const
|
|
454
|
+
const c = packColor(r, g, b, a);
|
|
455
|
+
let pen = x;
|
|
456
|
+
for (const ch of str) {
|
|
442
|
-
|
|
457
|
+
const gl = env.glyph(ch.codePointAt(0));
|
|
443
|
-
|
|
458
|
+
if (!gl) continue;
|
|
444
|
-
|
|
459
|
+
if (gl.w > 0) {
|
|
445
|
-
|
|
460
|
+
const x0 = pen - GLYPH_PAD * k;
|
|
446
|
-
|
|
461
|
+
const y0 = y - GLYPH_PAD * k;
|
|
447
|
-
const x1 = x0 + t.w / s;
|
|
448
|
-
const y1 = y0 + t.h / s;
|
|
449
|
-
|
|
462
|
+
quad(env.atlas, x0, y0, x0 + gl.w * k, y0, x0 + gl.w * k, y0 + gl.h * k, x0, y0 + gl.h * k, gl.u0, gl.v0, gl.u1, gl.v1, c);
|
|
463
|
+
}
|
|
464
|
+
pen += gl.adv * k;
|
|
465
|
+
}
|
|
450
466
|
},
|
|
451
467
|
setCamera,
|
|
452
468
|
resetCamera() {
|
|
@@ -535,7 +551,13 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
535
551
|
}
|
|
536
552
|
}
|
|
537
553
|
@fragment fn fs(i: VOut) -> @location(0) vec4f {
|
|
554
|
+
let fw = max(fwidth(i.uv.x), fwidth(i.uv.y));
|
|
538
|
-
|
|
555
|
+
let texel = pick(i.slot, i.uv);
|
|
556
|
+
if ((i.slot & ${SDF_BIT}u) != 0u) {
|
|
557
|
+
let w = clamp(fw * ${ATLAS_W}.0 / ${2 * GLYPH_SPREAD}.0 * 0.75, 0.02, 0.5);
|
|
558
|
+
return vec4f(i.c.rgb, i.c.a * smoothstep(0.5 - w, 0.5 + w, texel.a));
|
|
559
|
+
}
|
|
560
|
+
return texel * i.c;
|
|
539
561
|
}`;
|
|
540
562
|
}
|
|
541
563
|
const WGSL = makeWGSL();
|
|
@@ -651,6 +673,13 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
651
673
|
return t;
|
|
652
674
|
}
|
|
653
675
|
|
|
676
|
+
// Writes the pixels (rgba, w x h) of one rectangle of `t`; the caller keeps
|
|
677
|
+
// the full source in step so a restored device re-uploads it.
|
|
678
|
+
function writeRegion(t, x, y, w, h, rgba) {
|
|
679
|
+
if (recovering || !t.gpu) return;
|
|
680
|
+
device.queue.writeTexture({ texture: t.gpu, origin: [x, y] }, rgba, { bytesPerRow: w * 4 }, [w, h]);
|
|
681
|
+
}
|
|
682
|
+
|
|
654
683
|
// Bind groups by the textures of a draw's slots (unused slots hold the white texture).
|
|
655
684
|
function bindGroup(batch) {
|
|
656
685
|
let key = "";
|
|
@@ -679,6 +708,7 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
679
708
|
return {
|
|
680
709
|
name: "gpu-webgpu",
|
|
681
710
|
createTexture: (source, w, h, smooth) => createTexture(source, w, h, smooth, b.newTextureId()),
|
|
711
|
+
writeRegion,
|
|
682
712
|
setSmooth(t, smooth) {
|
|
683
713
|
t.smooth = !!smooth;
|
|
684
714
|
},
|
|
@@ -736,54 +766,147 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
736
766
|
|
|
737
767
|
// --- text -------------------------------------------------------------------------
|
|
738
768
|
|
|
769
|
+
// Squared euclidean distance transform of a w x h grid of starting squared
|
|
770
|
+
// distances (Felzenszwalb & Huttenlocher), written to `out`.
|
|
771
|
+
function edt(grid, w, h, out) {
|
|
739
|
-
|
|
772
|
+
const BIG = 1e20;
|
|
773
|
+
const n = Math.max(w, h);
|
|
774
|
+
const f = new Float64Array(n);
|
|
775
|
+
const d = new Float64Array(n);
|
|
740
|
-
|
|
776
|
+
const v = new Int32Array(n);
|
|
741
|
-
|
|
742
|
-
// Rasterises (string, px) runs once into backend textures, LRU-evicted.
|
|
743
|
-
|
|
777
|
+
const z = new Float64Array(n + 1);
|
|
744
|
-
const
|
|
778
|
+
const pass = (len) => {
|
|
745
|
-
|
|
779
|
+
let k = 0;
|
|
746
|
-
|
|
780
|
+
v[0] = 0;
|
|
747
|
-
|
|
781
|
+
z[0] = -BIG;
|
|
748
|
-
|
|
782
|
+
z[1] = BIG;
|
|
749
|
-
textTexture(str, px) {
|
|
750
|
-
|
|
783
|
+
for (let q = 1; q < len; q++) {
|
|
751
|
-
let
|
|
784
|
+
let sx;
|
|
752
|
-
|
|
785
|
+
for (;;) {
|
|
753
|
-
cache.delete(key);
|
|
754
|
-
cache.set(key, e);
|
|
755
|
-
|
|
786
|
+
const p = v[k];
|
|
756
|
-
return e.tex;
|
|
757
|
-
}
|
|
758
|
-
if (!scratch) {
|
|
759
|
-
scratch = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(8, 8) : document.createElement("canvas");
|
|
760
|
-
ctx2d = scratch.getContext("2d", { willReadFrequently: false });
|
|
761
|
-
}
|
|
762
|
-
ctx2d.font = px + "px sans-serif";
|
|
763
|
-
const w = Math.min(4096, Math.ceil(ctx2d.measureText(str).width) + 2 * TEXT_PAD);
|
|
764
|
-
|
|
787
|
+
sx = (f[q] + q * q - (f[p] + p * p)) / (2 * q - 2 * p);
|
|
765
|
-
scratch.width = w;
|
|
766
|
-
scratch.height = h;
|
|
767
|
-
ctx2d.font = px + "px sans-serif";
|
|
768
|
-
ctx2d.textBaseline = "top";
|
|
769
|
-
ctx2d.fillStyle = "#fff";
|
|
770
|
-
ctx2d.fillText(str, TEXT_PAD, TEXT_PAD);
|
|
771
|
-
const tex = getBackend().createTexture(ctx2d.getImageData(0, 0, w, h), w, h, true);
|
|
772
|
-
tex.pad = TEXT_PAD;
|
|
773
|
-
cache.set(key, (e = { tex, frame: frameNo }));
|
|
774
|
-
return tex;
|
|
775
|
-
},
|
|
776
|
-
// Evicts least-recently-used runs not used this frame (their textures may be queued).
|
|
777
|
-
endFrame() {
|
|
778
|
-
frameNo++;
|
|
779
|
-
|
|
788
|
+
if (sx <= z[k]) k--;
|
|
780
|
-
for (const [k, e] of cache) {
|
|
781
|
-
if (cache.size <= TEXT_CACHE_MAX || e.frame >= frameNo - 1) break;
|
|
782
|
-
getBackend().destroyTexture(e.tex);
|
|
783
|
-
|
|
789
|
+
else break;
|
|
784
790
|
}
|
|
791
|
+
k++;
|
|
792
|
+
v[k] = q;
|
|
793
|
+
z[k] = sx;
|
|
794
|
+
z[k + 1] = BIG;
|
|
785
|
-
}
|
|
795
|
+
}
|
|
796
|
+
k = 0;
|
|
797
|
+
for (let q = 0; q < len; q++) {
|
|
798
|
+
while (z[k + 1] < q) k++;
|
|
799
|
+
d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
|
|
800
|
+
}
|
|
786
801
|
};
|
|
802
|
+
for (let x = 0; x < w; x++) {
|
|
803
|
+
for (let y = 0; y < h; y++) f[y] = grid[y * w + x];
|
|
804
|
+
pass(h);
|
|
805
|
+
for (let y = 0; y < h; y++) out[y * w + x] = d[y];
|
|
806
|
+
}
|
|
807
|
+
for (let y = 0; y < h; y++) {
|
|
808
|
+
for (let x = 0; x < w; x++) f[x] = out[y * w + x];
|
|
809
|
+
pass(w);
|
|
810
|
+
for (let x = 0; x < w; x++) out[y * w + x] = d[x];
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// The glyph atlas: ASCII and Latin-1 are drawn at start-up with the page's
|
|
815
|
+
// sans-serif font, other characters when first used. The pixels are kept so a
|
|
816
|
+
// restored GPU device can upload them again. A glyph that no longer fits in the
|
|
817
|
+
// atlas draws as `?`.
|
|
818
|
+
function makeGlyphAtlas(getBackend, atlas) {
|
|
819
|
+
const data = new Uint8ClampedArray(ATLAS_W * ATLAS_H * 4);
|
|
820
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
821
|
+
data[i] = data[i + 1] = data[i + 2] = 255;
|
|
822
|
+
}
|
|
823
|
+
atlas.source = { data };
|
|
824
|
+
const glyphs = new Map();
|
|
825
|
+
const scratch = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(8, 8) : document.createElement("canvas");
|
|
826
|
+
const c2d = scratch.getContext("2d", { willReadFrequently: true });
|
|
827
|
+
const font = GLYPH_BASE + "px sans-serif";
|
|
828
|
+
let px = 0;
|
|
829
|
+
let py = 0;
|
|
830
|
+
let rowH = 0;
|
|
831
|
+
let full = false;
|
|
832
|
+
|
|
833
|
+
function make(cp) {
|
|
834
|
+
const ch = String.fromCodePoint(cp);
|
|
835
|
+
c2d.font = font;
|
|
836
|
+
const adv = c2d.measureText(ch).width;
|
|
837
|
+
const w = Math.ceil(adv) + 2 * GLYPH_PAD;
|
|
838
|
+
const h = Math.ceil(GLYPH_BASE * 1.25) + 2 * GLYPH_PAD;
|
|
839
|
+
const blank = /^\s$/.test(ch);
|
|
840
|
+
const gl = { adv, w: blank ? 0 : w, h, u0: 0, v0: 0, u1: 0, v1: 0 };
|
|
841
|
+
if (blank) return gl;
|
|
842
|
+
if (px + w > ATLAS_W) {
|
|
843
|
+
px = 0;
|
|
844
|
+
py += rowH;
|
|
845
|
+
rowH = 0;
|
|
846
|
+
}
|
|
847
|
+
if (py + h > ATLAS_H) {
|
|
848
|
+
if (!full) console.warn("glyph atlas is full; further new characters draw as '?'");
|
|
849
|
+
full = true;
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
scratch.width = w;
|
|
853
|
+
scratch.height = h;
|
|
854
|
+
c2d.font = font;
|
|
855
|
+
c2d.textBaseline = "top";
|
|
856
|
+
c2d.fillStyle = "#fff";
|
|
857
|
+
c2d.fillText(ch, GLYPH_PAD, GLYPH_PAD);
|
|
858
|
+
const cov = c2d.getImageData(0, 0, w, h).data;
|
|
859
|
+
// Anti-aliased coverage places the outline between pixels: an edge pixel of
|
|
860
|
+
// coverage a is 0.5 - a pixels from it.
|
|
861
|
+
const BIG = 1e20;
|
|
862
|
+
const outer = new Float64Array(w * h); // squared distance outside the glyph
|
|
863
|
+
const inner = new Float64Array(w * h); // squared distance inside it
|
|
864
|
+
for (let i = 0; i < w * h; i++) {
|
|
865
|
+
const a = cov[i * 4 + 3] / 255;
|
|
866
|
+
if (a === 1) {
|
|
867
|
+
outer[i] = 0;
|
|
868
|
+
inner[i] = BIG;
|
|
869
|
+
} else if (a === 0) {
|
|
870
|
+
outer[i] = BIG;
|
|
871
|
+
inner[i] = 0;
|
|
872
|
+
} else {
|
|
873
|
+
const d = 0.5 - a;
|
|
874
|
+
outer[i] = d > 0 ? d * d : 0;
|
|
875
|
+
inner[i] = d > 0 ? 0 : d * d;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const dOut = new Float64Array(w * h);
|
|
879
|
+
const dIn = new Float64Array(w * h);
|
|
880
|
+
edt(outer, w, h, dOut);
|
|
881
|
+
edt(inner, w, h, dIn);
|
|
882
|
+
const cell = new Uint8ClampedArray(w * h * 4);
|
|
883
|
+
for (let i = 0; i < w * h; i++) {
|
|
884
|
+
const sd = Math.sqrt(dOut[i]) - Math.sqrt(dIn[i]); // > 0 outside
|
|
885
|
+
cell[i * 4] = cell[i * 4 + 1] = cell[i * 4 + 2] = 255;
|
|
886
|
+
cell[i * 4 + 3] = Math.round(Math.min(1, Math.max(0, 0.5 - sd / (2 * GLYPH_SPREAD))) * 255);
|
|
887
|
+
}
|
|
888
|
+
for (let y = 0; y < h; y++) data.set(cell.subarray(y * w * 4, (y + 1) * w * 4), ((py + y) * ATLAS_W + px) * 4);
|
|
889
|
+
getBackend().writeRegion(atlas, px, py, w, h, cell);
|
|
890
|
+
gl.u0 = px / ATLAS_W;
|
|
891
|
+
gl.v0 = py / ATLAS_H;
|
|
892
|
+
gl.u1 = (px + w) / ATLAS_W;
|
|
893
|
+
gl.v1 = (py + h) / ATLAS_H;
|
|
894
|
+
px += w;
|
|
895
|
+
rowH = Math.max(rowH, h);
|
|
896
|
+
return gl;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function glyph(cp) {
|
|
900
|
+
let g = glyphs.get(cp);
|
|
901
|
+
if (g === undefined) {
|
|
902
|
+
g = make(cp);
|
|
903
|
+
glyphs.set(cp, g);
|
|
904
|
+
}
|
|
905
|
+
return g || glyphs.get(63) || null;
|
|
906
|
+
}
|
|
907
|
+
for (let cp = 32; cp < 127; cp++) glyph(cp);
|
|
908
|
+
for (let cp = 160; cp < 256; cp++) glyph(cp);
|
|
909
|
+
return glyph;
|
|
787
910
|
}
|
|
788
911
|
|
|
789
912
|
// --- start-up --------------------------------------------------------------------
|
|
@@ -806,8 +929,8 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
806
929
|
document.body.append(Object.assign(document.createElement("pre"), { textContent: text, style: "position:fixed;top:0;left:0;color:" + color + ";margin:8px;font-size:18px" }));
|
|
807
930
|
|
|
808
931
|
let backend = null;
|
|
809
|
-
const
|
|
932
|
+
const env = { glyph: () => null, atlas: null };
|
|
810
|
-
const b = makeBatcher({
|
|
933
|
+
const b = makeBatcher({ glyph: (cp) => env.glyph(cp), get atlas() { return env.atlas; } });
|
|
811
934
|
try {
|
|
812
935
|
backend = await makeWebGPU(canvas, b, (reason, restored) => {
|
|
813
936
|
if (restored) {
|
|
@@ -826,6 +949,10 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
826
949
|
message(msg, "#f55");
|
|
827
950
|
return;
|
|
828
951
|
}
|
|
952
|
+
// The atlas is a backend texture (smooth, SDF) built once the backend exists.
|
|
953
|
+
env.atlas = backend.createTexture(null, ATLAS_W, ATLAS_H, true);
|
|
954
|
+
env.atlas.sdf = true;
|
|
955
|
+
env.glyph = makeGlyphAtlas(() => backend, env.atlas);
|
|
829
956
|
globalThis.plumRenderer = backend.name;
|
|
830
957
|
console.log("plumRenderer: " + backend.name);
|
|
831
958
|
|
|
@@ -941,7 +1068,6 @@ ${cases} default: { return vec4f(1.0); }
|
|
|
941
1068
|
const t2 = m ? performance.now() : 0;
|
|
942
1069
|
const draws = backend.submit(b);
|
|
943
1070
|
if (m) { m.n++; m.u += t1 - t0; m.d += t2 - t1; m.r += performance.now() - t2; m.dr += draws; }
|
|
944
|
-
text.endFrame();
|
|
945
1071
|
} catch (e) {
|
|
946
1072
|
console.error("frame failed:", e);
|
|
947
1073
|
return;
|