plum

#treesitter#compiler#wasm

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. The backend is WebGPU where the
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
- // browser has it; there is no fallback (a page without navigator.gpu says so).
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) and the linear-filter flag (bit 8).
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.textTexture(str, px) -> { w, h, pad } texture object for a text run
276
+ // env.glyph(codePoint) -> { adv, w, h, u0, v0, u1, v1, tex } (sizes in atlas
265
- // (white glyphs on transparent, `pad` px of margin on every side), or null.
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. The run is rasterised at its on-screen
449
+ // `x, y` is the text's top-left. Each glyph is a quad of the SDF atlas scaled
438
- // size in device pixels (size * camera scale * dpr), so it is drawn 1:1.
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 px = Math.max(1, Math.round(size * cam.s * dpr));
454
+ const c = packColor(r, g, b, a);
455
+ let pen = x;
456
+ for (const ch of str) {
442
- const t = env.textTexture(str, px);
457
+ const gl = env.glyph(ch.codePointAt(0));
443
- if (!t) return;
458
+ if (!gl) continue;
444
- const s = cam.s * dpr;
459
+ if (gl.w > 0) {
445
- const x0 = x - t.pad / s;
460
+ const x0 = pen - GLYPH_PAD * k;
446
- const y0 = y - t.pad / s;
461
+ const y0 = y - GLYPH_PAD * k;
447
- const x1 = x0 + t.w / s;
448
- const y1 = y0 + t.h / s;
449
- quad(t, x0, y0, x1, y0, x1, y1, x0, y1, 0, 0, 1, 1, packColor(r, g, b, a));
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
- return pick(i.slot, i.uv) * i.c;
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
- const TEXT_PAD = 1;
772
+ const BIG = 1e20;
773
+ const n = Math.max(w, h);
774
+ const f = new Float64Array(n);
775
+ const d = new Float64Array(n);
740
- const TEXT_CACHE_MAX = 512;
776
+ const v = new Int32Array(n);
741
-
742
- // Rasterises (string, px) runs once into backend textures, LRU-evicted.
743
- function makeTextCache(getBackend) {
777
+ const z = new Float64Array(n + 1);
744
- const cache = new Map(); // "px|str" -> { tex, frame }; Map order = LRU order
778
+ const pass = (len) => {
745
- let frameNo = 0;
779
+ let k = 0;
746
- let scratch = null;
780
+ v[0] = 0;
747
- let ctx2d = null;
781
+ z[0] = -BIG;
748
- return {
782
+ z[1] = BIG;
749
- textTexture(str, px) {
750
- const key = px + "|" + str;
783
+ for (let q = 1; q < len; q++) {
751
- let e = cache.get(key);
784
+ let sx;
752
- if (e) {
785
+ for (;;) {
753
- cache.delete(key);
754
- cache.set(key, e);
755
- e.frame = frameNo;
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
- const h = Math.min(4096, Math.ceil(px * 1.4) + 2 * TEXT_PAD);
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
- if (cache.size <= TEXT_CACHE_MAX) return;
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
- cache.delete(k);
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 text = makeTextCache(() => backend);
932
+ const env = { glyph: () => null, atlas: null };
810
- const b = makeBatcher({ textTexture: text.textTexture });
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;