@aethervtt/shard-text 0.0.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.
@@ -0,0 +1,36 @@
1
+ // The subset of opentype.js (which ships no types) that @aethervtt/shard-text uses.
2
+ declare module 'opentype.js' {
3
+ export type PathCommand =
4
+ | { type: 'M'; x: number; y: number }
5
+ | { type: 'L'; x: number; y: number }
6
+ | { type: 'Q'; x1: number; y1: number; x: number; y: number }
7
+ | { type: 'C'; x1: number; y1: number; x2: number; y2: number; x: number; y: number }
8
+ | { type: 'Z' }
9
+
10
+ export interface Glyph {
11
+ index: number
12
+ name: string | null
13
+ advanceWidth: number | undefined
14
+ leftSideBearing: number | undefined
15
+ path: { commands: PathCommand[] }
16
+ }
17
+
18
+ export interface Font {
19
+ unitsPerEm: number
20
+ ascender: number
21
+ descender: number
22
+ numGlyphs: number
23
+ outlinesFormat: 'truetype' | 'cff'
24
+ names: Record<string, Record<string, string> | undefined>
25
+ glyphs: { get(index: number): Glyph }
26
+ kerningPairs: Record<string, number>
27
+ tables: {
28
+ cmap: { glyphIndexMap: Record<number, number> }
29
+ hhea: { ascender: number; descender: number; lineGap: number }
30
+ os2?: { sTypoAscender: number; sTypoDescender: number; sTypoLineGap: number }
31
+ [name: string]: unknown
32
+ }
33
+ }
34
+
35
+ export function parse(buffer: ArrayBuffer, options?: { lowMemory?: boolean }): Font
36
+ }
@@ -0,0 +1,2 @@
1
+ /** Text and ScreenText: MSDF glyphs in the world (after transparent 3D) and over the image. */
2
+ export declare const textPlugin: import("@aethervtt/shard-runtime").Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,43 @@
1
+ import { Last } from "@aethervtt/shard-core";
2
+ import { Gpu, Graph, RenderDescribers, RenderSet, Shaders } from "@aethervtt/shard-render";
3
+ import { definePlugin } from "@aethervtt/shard-runtime";
4
+ import * as componentsModule from "./components.js";
5
+ import * as importerModule from "./importer.js";
6
+ import { Fonts } from "./importer.js";
7
+ import * as localeModule from "./locale.js";
8
+ import { installLocalization } from "./locale.js";
9
+ import * as previewModule from "./preview.js";
10
+ import * as renderModule from "./render.js";
11
+ import {
12
+ describeText,
13
+ prepareTexts,
14
+ screenTextNode,
15
+ TextRenderer,
16
+ TextStore,
17
+ textNode
18
+ } from "./render.js";
19
+ import { TEXT_SHADERS } from "./shaders.js";
20
+ const textPlugin = definePlugin({
21
+ name: "text",
22
+ provides: [componentsModule, importerModule, localeModule, previewModule, renderModule],
23
+ dependencies: ["render/forward"],
24
+ build(app) {
25
+ app.world.initResource(Fonts);
26
+ installLocalization(app);
27
+ app.addSystems(Last, prepareTexts.inSet(RenderSet.Prepare));
28
+ },
29
+ ready(app) {
30
+ const world = app.world;
31
+ app.insertResource(TextRenderer, new TextStore(world.resource(Gpu)));
32
+ const shaders = world.resource(Shaders);
33
+ for (const [path, source] of Object.entries(TEXT_SHADERS))
34
+ shaders.register(path, source, `engine:${path}`);
35
+ const graph = world.resource(Graph);
36
+ graph.addNode("text", textNode(world));
37
+ graph.addNode("text/screen", screenTextNode(world));
38
+ world.initResource(RenderDescribers).set("text", (w) => describeText(w));
39
+ }
40
+ });
41
+ export {
42
+ textPlugin
43
+ };
@@ -0,0 +1,2 @@
1
+ /** A font specimen: sample lines at 16, 32, and 64 px, rendered from the distance field on the CPU. */
2
+ export declare const fontPreview: import("@aethervtt/shard-assets").AssetPreview;
@@ -0,0 +1,76 @@
1
+ import { assetServer, defineAssetPreview } from "@aethervtt/shard-assets";
2
+ import { ShardError } from "@aethervtt/shard-core";
3
+ import { readKtx2 } from "@aethervtt/shard-texture";
4
+ import { Fonts } from "./importer.js";
5
+ import { layoutText } from "./layout.js";
6
+ const SPECIMEN = [
7
+ "AaBbCcDdEeFfGg 0123456789",
8
+ "The quick brown fox jumps over the lazy dog.",
9
+ "Sharp at any size \u2014 \xC0\xC9\xCE\xF5\xFC ?!&@"
10
+ ];
11
+ const fontPreview = defineAssetPreview("Font", async (world, path, width, height) => {
12
+ const server = assetServer(world);
13
+ await server.whenSettled([path]);
14
+ const font = world.resource(Fonts).get(server.resolve(path));
15
+ if (!font) throw new ShardError("text/not-loaded", `${path} isn't loaded`, { path });
16
+ const pages = await Promise.all(
17
+ font.pages.map(async (page, i) => {
18
+ if (page.texture?.levels)
19
+ return { w: page.width, h: page.height, data: page.texture.levels[0] };
20
+ const ktx = readKtx2(
21
+ (await server.artifact(`${path}#${i === 0 ? "Atlas" : `Atlas/${i}`}`)).bytes
22
+ );
23
+ return { w: ktx.width, h: ktx.height, data: ktx.levels[0] };
24
+ })
25
+ );
26
+ const out = new Uint8Array(width * height * 4);
27
+ for (let p = 0; p < width * height; p++) out.set([24, 26, 32, 255], p * 4);
28
+ let baseline = 8;
29
+ for (const px of [16, 32, 64]) {
30
+ for (const line of SPECIMEN) {
31
+ baseline += px * 1.25;
32
+ if (baseline > height) break;
33
+ const layout = layoutText(font, line, { size: px, anchor: [0, 0] });
34
+ for (let q = 0; q < layout.count; q++) {
35
+ const page = pages[layout.pages[layout.page[q]].index];
36
+ if (!page) continue;
37
+ const gFont = layout.glyphs[q].font;
38
+ const screenRange = gFont.range * (px / gFont.size);
39
+ const [x, y, w, h] = layout.quads.subarray(q * 4, q * 4 + 4);
40
+ const [u0, v0, u1, v1] = layout.uvs.subarray(q * 4, q * 4 + 4);
41
+ for (let j = Math.floor(baseline - y - h); j < Math.ceil(baseline - y); j++) {
42
+ if (j < 0 || j >= height) continue;
43
+ for (let i = Math.floor(8 + x); i < Math.ceil(8 + x + w); i++) {
44
+ if (i < 0 || i >= width) continue;
45
+ const fu = (i + 0.5 - 8 - x) / w;
46
+ const fv = (j + 0.5 - (baseline - y - h)) / h;
47
+ const sd = sample(page, u0 + (u1 - u0) * fu, v0 + (v1 - v0) * fv);
48
+ const a = Math.min(1, Math.max(0, screenRange * (sd - 0.5) + 0.5));
49
+ const o = (j * width + i) * 4;
50
+ for (let k = 0; k < 3; k++) out[o + k] = Math.round(out[o + k] * (1 - a) + 240 * a);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return { width, height, data: out };
57
+ });
58
+ function sample(page, u, v) {
59
+ const x = Math.min(page.w - 1, Math.max(0, u * page.w - 0.5));
60
+ const y = Math.min(page.h - 1, Math.max(0, v * page.h - 0.5));
61
+ const x0 = Math.floor(x);
62
+ const y0 = Math.floor(y);
63
+ const x1 = Math.min(page.w - 1, x0 + 1);
64
+ const y1 = Math.min(page.h - 1, y0 + 1);
65
+ const fx = x - x0;
66
+ const fy = y - y0;
67
+ const c = [0, 0, 0];
68
+ for (let k = 0; k < 3; k++) {
69
+ const at = (px, py) => page.data[(py * page.w + px) * 4 + k] / 255;
70
+ c[k] = (at(x0, y0) * (1 - fx) + at(x1, y0) * fx) * (1 - fy) + (at(x0, y1) * (1 - fx) + at(x1, y1) * fx) * fy;
71
+ }
72
+ return Math.max(Math.min(c[0], c[1]), Math.min(Math.max(c[0], c[1]), c[2]));
73
+ }
74
+ export {
75
+ fontPreview
76
+ };
@@ -0,0 +1,114 @@
1
+ import { type Entity, type World } from '@aethervtt/shard-core';
2
+ import { GpuBuffer, type GpuContext } from '@aethervtt/shard-gpu';
3
+ import { type NodeDescriptor } from '@aethervtt/shard-render';
4
+ import type { Font, FontPage } from './font.js';
5
+ import { TextLayout } from './layout.js';
6
+ interface TextState {
7
+ entity: Entity;
8
+ space: number;
9
+ layout: TextLayout;
10
+ /** What the layout was made from: re-lay out when any of these change. */
11
+ value: string;
12
+ font: Font | undefined;
13
+ fontVersion: number;
14
+ size: number;
15
+ align: number;
16
+ anchorX: number;
17
+ anchorY: number;
18
+ maxWidth: number;
19
+ lineHeight: number;
20
+ /** Record index in the text buffer. */
21
+ index: number;
22
+ seen: number;
23
+ }
24
+ /** Glyphs of one atlas page in one space: one draw. */
25
+ interface GlyphGroup {
26
+ page: FontPage;
27
+ space: number;
28
+ first: number;
29
+ count: number;
30
+ }
31
+ /**
32
+ * Laid-out text on the GPU. Each Text or ScreenText keeps its layout and relays out only when its
33
+ * value, font, size, or layout options change (or its font gains glyphs). Glyph records are
34
+ * rebuilt, grouped by atlas page, only on frames where some layout changed; transforms and colors
35
+ * update the small per-text records.
36
+ */
37
+ export declare class TextStore {
38
+ readonly states: Map<number, TextState>;
39
+ glyphs: Float32Array<ArrayBuffer>;
40
+ glyphU32: Uint32Array<ArrayBuffer>;
41
+ glyphCount: number;
42
+ texts: Float32Array<ArrayBuffer>;
43
+ textU32: Uint32Array<ArrayBuffer>;
44
+ textCount: number;
45
+ readonly groups: GlyphGroup[];
46
+ groupCount: number;
47
+ readonly glyphBuffer: GpuBuffer;
48
+ readonly textBuffer: GpuBuffer;
49
+ /** Layouts redone this frame, and characters nothing could draw. */
50
+ relayouts: number;
51
+ missing: number;
52
+ uploadedBytes: number;
53
+ private frame;
54
+ private freeIndices;
55
+ private nextIndex;
56
+ dirtyGlyphs: boolean;
57
+ constructor(gpu: GpuContext);
58
+ beginFrame(): void;
59
+ state(entity: Entity, space: number): TextState;
60
+ /** Forgets texts not seen this frame (despawned, hidden, or their component removed). */
61
+ sweep(): void;
62
+ /** Group per (page, space) while rebuilding: [world, screen], -1 for none yet. */
63
+ private readonly groupOf;
64
+ private groupFor;
65
+ /** Rebuilds the glyph records, grouped by (page, space), from every layout. */
66
+ rebuild(): void;
67
+ }
68
+ export declare const TextRenderer: import("@aethervtt/shard-core").ResourceDef<TextStore>;
69
+ /**
70
+ * Lays out changed text (Text in the world, ScreenText on screen), writes per-text style and
71
+ * placement records, and rebuilds glyph records on frames where a layout changed.
72
+ */
73
+ export declare const prepareTexts: import("@aethervtt/shard-core").SystemDef<{
74
+ worldQ: import("@aethervtt/shard-core").Query;
75
+ screenQ: import("@aethervtt/shard-core").Query;
76
+ screen: {
77
+ corner: number;
78
+ x: number;
79
+ y: number;
80
+ };
81
+ }>;
82
+ interface TextCaches {
83
+ generation: number;
84
+ layouts?: {
85
+ view: GPUBindGroupLayout;
86
+ data: GPUBindGroupLayout;
87
+ page: GPUBindGroupLayout;
88
+ };
89
+ pipelines: Map<string, GPURenderPipeline>;
90
+ groups: Map<string, {
91
+ key: string;
92
+ group: GPUBindGroup;
93
+ }>;
94
+ views: Map<string, GpuBuffer>;
95
+ sampler?: GPUSampler;
96
+ }
97
+ export declare const TextCachesResource: import("@aethervtt/shard-core").ResourceDef<TextCaches>;
98
+ /** World text: after transparent 3D, depth-tested against the scene. */
99
+ export declare function textNode(world: World): NodeDescriptor;
100
+ /** Screen text: over the finished image, after screen sprites. */
101
+ export declare function screenTextNode(world: World): NodeDescriptor;
102
+ /** The text section of `render.describe`. */
103
+ export declare function describeText(world: World): {
104
+ texts: number;
105
+ glyphs: number;
106
+ drawCalls: number;
107
+ atlasPages: number;
108
+ runtimePages: number;
109
+ runtimeGlyphs: number;
110
+ missing: string[];
111
+ relayouts: number;
112
+ uploadedBytes: number;
113
+ } | undefined;
114
+ export {};