@forgeax/engine-graphics-extras 0.1.2
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 +202 -0
- package/README.md +165 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/tile-bits.test.d.ts +2 -0
- package/dist/__tests__/tile-bits.test.d.ts.map +1 -0
- package/dist/__tests__/video-capability-probe.unit.test.d.ts +2 -0
- package/dist/__tests__/video-capability-probe.unit.test.d.ts.map +1 -0
- package/dist/__tests__/video-loader.unit.test.d.ts +2 -0
- package/dist/__tests__/video-loader.unit.test.d.ts.map +1 -0
- package/dist/__tests__/video-player-component.unit.test.d.ts +2 -0
- package/dist/__tests__/video-player-component.unit.test.d.ts.map +1 -0
- package/dist/__tests__/video-player-multi-entity.unit.test.d.ts +2 -0
- package/dist/__tests__/video-player-multi-entity.unit.test.d.ts.map +1 -0
- package/dist/glyph-layout.d.ts +48 -0
- package/dist/glyph-layout.d.ts.map +1 -0
- package/dist/glyph-mesh-bake.d.ts +30 -0
- package/dist/glyph-mesh-bake.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +306 -0
- package/dist/index.mjs.map +1 -0
- package/dist/tile-bits.d.ts +21 -0
- package/dist/tile-bits.d.ts.map +1 -0
- package/dist/tileset-decoder.d.ts +4 -0
- package/dist/tileset-decoder.d.ts.map +1 -0
- package/dist/video-element-provider.d.ts +41 -0
- package/dist/video-element-provider.d.ts.map +1 -0
- package/dist/video-loader.d.ts +4 -0
- package/dist/video-loader.d.ts.map +1 -0
- package/dist/video-player-system.d.ts +37 -0
- package/dist/video-player-system.d.ts.map +1 -0
- package/dist/video-player.d.ts +23 -0
- package/dist/video-player.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/__tests__/tile-bits.test.ts +88 -0
- package/src/__tests__/video-capability-probe.unit.test.ts +130 -0
- package/src/__tests__/video-loader.unit.test.ts +27 -0
- package/src/__tests__/video-player-component.unit.test.ts +73 -0
- package/src/__tests__/video-player-multi-entity.unit.test.ts +75 -0
- package/src/glyph-layout.ts +208 -0
- package/src/glyph-mesh-bake.ts +151 -0
- package/src/index.ts +36 -0
- package/src/tile-bits.ts +77 -0
- package/src/tileset-decoder.ts +64 -0
- package/src/video-element-provider.ts +72 -0
- package/src/video-loader.ts +80 -0
- package/src/video-player-system.ts +61 -0
- package/src/video-player.ts +51 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras - glyph layout algorithm
|
|
2
|
+
// (feat-20260531-world-space-msdf-text-rendering M4 / w15).
|
|
3
|
+
//
|
|
4
|
+
// Pure functions: lay out a `GlyphText` string against a `FontAsset`'s glyph
|
|
5
|
+
// metrics into per-glyph quad vertices (position + uv) and indices, plus a
|
|
6
|
+
// conservative bounding-sphere radius for pick (plan-strategy D-5). NO ECS,
|
|
7
|
+
// NO GPU -- the mesh baking (12-float stride fill + register) is the job of
|
|
8
|
+
// `glyph-mesh-bake.ts` (w17); this module produces only the geometry data.
|
|
9
|
+
//
|
|
10
|
+
// Layout model (plan-strategy D-2 / D-4 / D-5):
|
|
11
|
+
// - left-aligned: the pen advances by `metric.advance * fontSize` per glyph.
|
|
12
|
+
// - baseline at local y = 0 on the first line; the local space is Y-up so a
|
|
13
|
+
// glyph quad's top edge sits at `penY - bearingY*s + size.h*s` and its
|
|
14
|
+
// bottom edge at `penY - bearingY*s` (BMFont yoffset measures DOWN from
|
|
15
|
+
// the line top; we negate into Y-up so higher bearingY -> lower top).
|
|
16
|
+
// - `\n` resets penX to 0 and drops penY by `lineHeight * fontSize`
|
|
17
|
+
// (line-2 baseline = -lineHeight, AC-21).
|
|
18
|
+
// - missing codepoint -> notdef TOFU fallback; the glyph still counts and
|
|
19
|
+
// emits a quad (AC-14). A codepoint with neither a metric nor a notdef
|
|
20
|
+
// emits no quad but still advances by the notdef-or-zero advance.
|
|
21
|
+
// - empty string -> zero vertices / zero indices / radius 0 (the bake
|
|
22
|
+
// helper registers a 0-vertex mesh, which is legal; pick skips it).
|
|
23
|
+
//
|
|
24
|
+
// Vertex layout produced here is the 12-float canonical stride
|
|
25
|
+
// (PROCEDURAL_FLOATS_PER_VERTEX): position(vec3) + normal(vec3) + uv(vec2) +
|
|
26
|
+
// tangent(vec4). This module writes position + uv as real values and leaves
|
|
27
|
+
// normal/tangent as placeholder constants so the buffer is register-ready
|
|
28
|
+
// without a second pass (R-2: 12-float stride is a hard register gate). The
|
|
29
|
+
// per-vertex offsets are exported so the bake helper + tests share one SSOT.
|
|
30
|
+
|
|
31
|
+
import { PROCEDURAL_FLOATS_PER_VERTEX } from '@forgeax/engine-geometry';
|
|
32
|
+
import type { FontAsset, GlyphMetric } from '@forgeax/engine-types';
|
|
33
|
+
import { TextError } from '@forgeax/engine-types';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Canonical 12-float vertex stride (position vec3 + normal vec3 + uv vec2 +
|
|
37
|
+
* tangent vec4) owned by `PROCEDURAL_FLOATS_PER_VERTEX` in
|
|
38
|
+
* `@forgeax/engine-geometry`. R-2: the baked mesh must satisfy this stride or `register`
|
|
39
|
+
* fail-fasts with `mesh-vertex-stride-mismatch`.
|
|
40
|
+
*/
|
|
41
|
+
/** Byte-free float offsets within a single 12-float vertex. */
|
|
42
|
+
export const VERTEX_OFFSET = {
|
|
43
|
+
position: 0, // vec3
|
|
44
|
+
normal: 3, // vec3 (placeholder (0,0,1))
|
|
45
|
+
uv: 6, // vec2
|
|
46
|
+
tangent: 8, // vec4 (placeholder (0,0,0,1))
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
/** Soft per-frame concurrent-font ceiling (plan-strategy D-8 / AC-20). */
|
|
50
|
+
export const FONT_CONCURRENCY_LIMIT = 8;
|
|
51
|
+
|
|
52
|
+
/** Layout output: per-glyph quad geometry + conservative sphere radius. */
|
|
53
|
+
export interface GlyphLayoutResult {
|
|
54
|
+
/** 12-float-stride interleaved vertices (4 vertices per glyph). */
|
|
55
|
+
readonly vertices: Float32Array;
|
|
56
|
+
/** Triangle indices (6 per glyph: two triangles). */
|
|
57
|
+
readonly indices: Uint16Array;
|
|
58
|
+
/**
|
|
59
|
+
* Conservative bounding-sphere radius from the anchor (local origin) to the
|
|
60
|
+
* farthest glyph quad corner (plan-strategy D-5). The bake helper turns this
|
|
61
|
+
* into a cube AABB (half-side = radius) so pick is orientation-independent.
|
|
62
|
+
*/
|
|
63
|
+
readonly radius: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Module-level set tracking distinct FontAsset handle ids active in the
|
|
67
|
+
// current frame. The layout system resets this at the top of each frame
|
|
68
|
+
// (resetFontConcurrency) and calls trackFontConcurrency once per distinct
|
|
69
|
+
// font; the 9th distinct font throws a structured TextError (D-8 rejects
|
|
70
|
+
// silently evicting the oldest font).
|
|
71
|
+
const activeFontIds = new Set<number>();
|
|
72
|
+
|
|
73
|
+
/** Reset the per-frame concurrent-font tracker (call once at frame start). */
|
|
74
|
+
export function resetFontConcurrency(): void {
|
|
75
|
+
activeFontIds.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Track one distinct FontAsset handle id as active this frame. Re-tracking an
|
|
80
|
+
* already-active id is a no-op; the (N+1)th distinct id beyond
|
|
81
|
+
* {@link FONT_CONCURRENCY_LIMIT} throws `TextError('font-concurrency-exceeded')`
|
|
82
|
+
* (plan-strategy D-8 / AC-20).
|
|
83
|
+
*/
|
|
84
|
+
export function trackFontConcurrency(fontId: number): void {
|
|
85
|
+
if (activeFontIds.has(fontId)) return;
|
|
86
|
+
if (activeFontIds.size >= FONT_CONCURRENCY_LIMIT) {
|
|
87
|
+
throw new TextError({
|
|
88
|
+
code: 'font-concurrency-exceeded',
|
|
89
|
+
expected: String(FONT_CONCURRENCY_LIMIT),
|
|
90
|
+
hint: 'reuse a shared FontAsset across labels, or split text into fewer distinct fonts per frame',
|
|
91
|
+
detail: { active: activeFontIds.size, limit: FONT_CONCURRENCY_LIMIT, rejected: fontId },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
activeFontIds.add(fontId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const NEWLINE = '\n'.codePointAt(0) as number;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Lay out `text` against `font` at `fontSize`, producing per-glyph quad
|
|
101
|
+
* geometry (12-float stride) + indices + the conservative sphere radius.
|
|
102
|
+
*
|
|
103
|
+
* @param font The resolved FontAsset (glyph metrics + common block).
|
|
104
|
+
* @param text The authoring string (`\n` starts a new line).
|
|
105
|
+
* @param fontSize Uniform scale applied to all metric units.
|
|
106
|
+
*/
|
|
107
|
+
export function layoutGlyphText(
|
|
108
|
+
font: FontAsset,
|
|
109
|
+
text: string,
|
|
110
|
+
fontSize: number,
|
|
111
|
+
): GlyphLayoutResult {
|
|
112
|
+
const s = fontSize;
|
|
113
|
+
const { atlasWidth, atlasHeight, lineHeight } = font.common;
|
|
114
|
+
|
|
115
|
+
// First pass over code points: collect the renderable glyph quads.
|
|
116
|
+
const quads: Array<{ x0: number; y0: number; x1: number; y1: number; m: GlyphMetric }> = [];
|
|
117
|
+
let penX = 0;
|
|
118
|
+
let penY = 0;
|
|
119
|
+
let maxCornerDist = 0;
|
|
120
|
+
|
|
121
|
+
// Iterate by code point so surrogate pairs count as one glyph.
|
|
122
|
+
for (const ch of text) {
|
|
123
|
+
const cp = ch.codePointAt(0) as number;
|
|
124
|
+
if (cp === NEWLINE) {
|
|
125
|
+
penX = 0;
|
|
126
|
+
penY -= lineHeight * s;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const metric = font.glyphs[cp] ?? font.notdef;
|
|
130
|
+
if (metric === undefined) {
|
|
131
|
+
// Neither a glyph nor a notdef -> nothing to render; advance by zero so
|
|
132
|
+
// the cursor does not jump (rare: a font with no notdef and missing cp).
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// Quad corners in Y-up local space (baseline at penY).
|
|
136
|
+
const x0 = penX + metric.bearingX * s;
|
|
137
|
+
const yTop = penY - metric.bearingY * s + metric.size.h * s;
|
|
138
|
+
const yBot = penY - metric.bearingY * s;
|
|
139
|
+
const x1 = x0 + metric.size.w * s;
|
|
140
|
+
quads.push({ x0, y0: yBot, x1, y1: yTop, m: metric });
|
|
141
|
+
maxCornerDist = Math.max(
|
|
142
|
+
maxCornerDist,
|
|
143
|
+
Math.hypot(x0, yBot),
|
|
144
|
+
Math.hypot(x1, yBot),
|
|
145
|
+
Math.hypot(x0, yTop),
|
|
146
|
+
Math.hypot(x1, yTop),
|
|
147
|
+
);
|
|
148
|
+
penX += metric.advance * s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const glyphCount = quads.length;
|
|
152
|
+
const vertices = new Float32Array(glyphCount * 4 * PROCEDURAL_FLOATS_PER_VERTEX);
|
|
153
|
+
const indices = new Uint16Array(glyphCount * 6);
|
|
154
|
+
|
|
155
|
+
for (let g = 0; g < glyphCount; g++) {
|
|
156
|
+
const q = quads[g] as (typeof quads)[number];
|
|
157
|
+
const { region } = q.m;
|
|
158
|
+
// Atlas UV (top-left origin) normalized into [0,1].
|
|
159
|
+
const u0 = region.x / atlasWidth;
|
|
160
|
+
const u1 = (region.x + region.w) / atlasWidth;
|
|
161
|
+
const v0 = region.y / atlasHeight;
|
|
162
|
+
const v1 = (region.y + region.h) / atlasHeight;
|
|
163
|
+
// 4 corners: TL, TR, BR, BL (CCW); position z = 0 (billboard before).
|
|
164
|
+
// uv pairs the top edge (y1) with v0 and the bottom edge (y0) with v1.
|
|
165
|
+
writeVertex(vertices, g * 4 + 0, q.x0, q.y1, u0, v0);
|
|
166
|
+
writeVertex(vertices, g * 4 + 1, q.x1, q.y1, u1, v0);
|
|
167
|
+
writeVertex(vertices, g * 4 + 2, q.x1, q.y0, u1, v1);
|
|
168
|
+
writeVertex(vertices, g * 4 + 3, q.x0, q.y0, u0, v1);
|
|
169
|
+
const vbase = g * 4;
|
|
170
|
+
const ibase = g * 6;
|
|
171
|
+
indices[ibase + 0] = vbase + 0;
|
|
172
|
+
indices[ibase + 1] = vbase + 1;
|
|
173
|
+
indices[ibase + 2] = vbase + 2;
|
|
174
|
+
indices[ibase + 3] = vbase + 0;
|
|
175
|
+
indices[ibase + 4] = vbase + 2;
|
|
176
|
+
indices[ibase + 5] = vbase + 3;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { vertices, indices, radius: maxCornerDist };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Write one 12-float vertex (position + placeholder normal + uv + placeholder tangent). */
|
|
183
|
+
function writeVertex(
|
|
184
|
+
out: Float32Array,
|
|
185
|
+
vertexIndex: number,
|
|
186
|
+
x: number,
|
|
187
|
+
y: number,
|
|
188
|
+
u: number,
|
|
189
|
+
v: number,
|
|
190
|
+
): void {
|
|
191
|
+
const o = vertexIndex * PROCEDURAL_FLOATS_PER_VERTEX;
|
|
192
|
+
// position (vec3)
|
|
193
|
+
out[o + VERTEX_OFFSET.position + 0] = x;
|
|
194
|
+
out[o + VERTEX_OFFSET.position + 1] = y;
|
|
195
|
+
out[o + VERTEX_OFFSET.position + 2] = 0;
|
|
196
|
+
// normal placeholder (0,0,1)
|
|
197
|
+
out[o + VERTEX_OFFSET.normal + 0] = 0;
|
|
198
|
+
out[o + VERTEX_OFFSET.normal + 1] = 0;
|
|
199
|
+
out[o + VERTEX_OFFSET.normal + 2] = 1;
|
|
200
|
+
// uv (vec2)
|
|
201
|
+
out[o + VERTEX_OFFSET.uv + 0] = u;
|
|
202
|
+
out[o + VERTEX_OFFSET.uv + 1] = v;
|
|
203
|
+
// tangent placeholder (0,0,0,1)
|
|
204
|
+
out[o + VERTEX_OFFSET.tangent + 0] = 0;
|
|
205
|
+
out[o + VERTEX_OFFSET.tangent + 1] = 0;
|
|
206
|
+
out[o + VERTEX_OFFSET.tangent + 2] = 0;
|
|
207
|
+
out[o + VERTEX_OFFSET.tangent + 3] = 1;
|
|
208
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras - glyph mesh bake helper
|
|
2
|
+
// (feat-20260531-world-space-msdf-text-rendering M4 / w17).
|
|
3
|
+
//
|
|
4
|
+
// Turns the pure layout output (w15) into a registered `MeshAsset` and a
|
|
5
|
+
// conservative bounding-sphere cube AABB (plan-strategy D-4 / D-5). Called by
|
|
6
|
+
// the `glyphTextLayoutSystem` (w18) -- not a public AI-user API; AI users
|
|
7
|
+
// declare a `GlyphText` component and the system bakes behind the scenes.
|
|
8
|
+
//
|
|
9
|
+
// 12-float stride (R-2 hard gate): the layout already produced interleaved
|
|
10
|
+
// vertices at the canonical `PROCEDURAL_FLOATS_PER_VERTEX` stride (position + uv
|
|
11
|
+
// real, normal (0,0,1) / tangent (0,0,0,1) placeholder). We deinterleave into
|
|
12
|
+
// the `VertexAttributeMap` (position / normal / uv / tangent) so
|
|
13
|
+
// `AssetRegistry.register` can derive the GPU vertex layout + AABB. The
|
|
14
|
+
// register call fail-fasts with `mesh-vertex-stride-mismatch` if the stride is
|
|
15
|
+
// ever wrong -- this module never bypasses that gate.
|
|
16
|
+
//
|
|
17
|
+
// Empty string -> 0-vertex / 0-index mesh; `register` accepts the empty mesh
|
|
18
|
+
// (validateMeshPayload branch (b)) and pick skips it (inverted-infinity AABB).
|
|
19
|
+
//
|
|
20
|
+
// Conservative cube AABB (D-5): pick must be orientation-independent (text may
|
|
21
|
+
// billboard toward the camera at draw time, but `pick.ts` raycasts against the
|
|
22
|
+
// static local AABB x world matrix without the billboard rotation). The baked
|
|
23
|
+
// mesh's `attributes.position` carries the 8 corners of a cube centered at the
|
|
24
|
+
// anchor with half-side = the layout radius R; `AssetRegistry.register`
|
|
25
|
+
// computes the local AABB from that position attribute (`computeAABB`), so the
|
|
26
|
+
// REGISTERED mesh AABB is the conservative cube -- a ray that would hit the
|
|
27
|
+
// text from any in-plane orientation is caught. The GPU vertex buffer is built
|
|
28
|
+
// from the interleaved `vertices` (real glyph quads), which is fully decoupled
|
|
29
|
+
// from `attributes.position` (uploadMeshById reads `mesh.vertices`). `pick.ts`
|
|
30
|
+
// is NOT modified -- the cube is purely a bake-step property of the mesh.
|
|
31
|
+
|
|
32
|
+
import type { World } from '@forgeax/engine-ecs';
|
|
33
|
+
import {
|
|
34
|
+
buildMeshAttributeMapForUvSets,
|
|
35
|
+
PROCEDURAL_FLOATS_PER_VERTEX,
|
|
36
|
+
} from '@forgeax/engine-geometry';
|
|
37
|
+
import { ok, type Result } from '@forgeax/engine-rhi';
|
|
38
|
+
import type { AssetError, Handle, MeshAsset } from '@forgeax/engine-types';
|
|
39
|
+
|
|
40
|
+
import type { GlyphLayoutResult } from './glyph-layout';
|
|
41
|
+
|
|
42
|
+
/** Result of baking a glyph layout into a registered mesh. */
|
|
43
|
+
export interface GlyphMeshBakeResult {
|
|
44
|
+
/** The registered unmanaged mesh handle (feed to `MeshFilter.assetHandle`). */
|
|
45
|
+
readonly handle: Handle<'MeshAsset', 'shared'>;
|
|
46
|
+
/**
|
|
47
|
+
* Conservative bounding-sphere cube AABB in local space: 6 floats
|
|
48
|
+
* [-R,-R,-R, R,R,R] centered at the anchor (plan-strategy D-5). Empty
|
|
49
|
+
* layout -> all-zero box.
|
|
50
|
+
*/
|
|
51
|
+
readonly aabb: Float32Array;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the MeshAsset POD (12-float stride) from a glyph layout. */
|
|
55
|
+
export function buildGlyphMeshAsset(layout: GlyphLayoutResult): MeshAsset {
|
|
56
|
+
const { vertices, indices, radius } = layout;
|
|
57
|
+
return {
|
|
58
|
+
kind: 'mesh',
|
|
59
|
+
vertices,
|
|
60
|
+
indices,
|
|
61
|
+
// `attributes.position` carries the 8 conservative-cube corners (half-side
|
|
62
|
+
// = radius) so `register` computes the orientation-independent cube AABB
|
|
63
|
+
// (D-5). The GPU vertex buffer is built from the interleaved `vertices`
|
|
64
|
+
// (uploadMeshById reads `mesh.vertices`), fully decoupled from this
|
|
65
|
+
// position attribute -- which exists only to drive `computeAABB`.
|
|
66
|
+
attributes: {
|
|
67
|
+
// Keep the conservative pick/cull position stream and the canonical
|
|
68
|
+
// 12-float upload layout in one geometry-owned projection.
|
|
69
|
+
...buildMeshAttributeMapForUvSets(1),
|
|
70
|
+
position: cubeCornerAttributes(radius).position,
|
|
71
|
+
},
|
|
72
|
+
submeshes: [
|
|
73
|
+
{
|
|
74
|
+
indexOffset: 0,
|
|
75
|
+
indexCount: indices.length,
|
|
76
|
+
vertexCount: vertices.length / PROCEDURAL_FLOATS_PER_VERTEX,
|
|
77
|
+
topology: 'triangle-list',
|
|
78
|
+
materialSlot: 0,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Conservative cube AABB centered at the anchor with half-side = layout radius. */
|
|
86
|
+
export function conservativeCubeAabb(radius: number): Float32Array {
|
|
87
|
+
return Float32Array.of(-radius, -radius, -radius, radius, radius, radius);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Bake a glyph layout into a registered mesh + conservative cube AABB.
|
|
92
|
+
*
|
|
93
|
+
* @param assets The AssetRegistry that owns the mesh handle lifecycle.
|
|
94
|
+
* @param layout The pure layout output from `layoutGlyphText` (w15).
|
|
95
|
+
* @returns `Result.ok({ handle, aabb })` or `Result.err(AssetError)` when
|
|
96
|
+
* `register` fail-fasts (e.g. stride mismatch -- should never happen for a
|
|
97
|
+
* layout produced by w15, but the gate is honored, not bypassed).
|
|
98
|
+
*/
|
|
99
|
+
export function bakeGlyphMesh(
|
|
100
|
+
world: World,
|
|
101
|
+
layout: GlyphLayoutResult,
|
|
102
|
+
): Result<GlyphMeshBakeResult, AssetError> {
|
|
103
|
+
const aabb = conservativeCubeAabb(layout.radius);
|
|
104
|
+
// feat-20260614 M8 (D-17/D-19): the baked text mesh is a runtime-minted
|
|
105
|
+
// user-tier asset allocated directly into the world's SharedRefStore. Unlike
|
|
106
|
+
// `AssetRegistry.catalog` (which runs `withMeshAabb` to compute the AABB from
|
|
107
|
+
// `attributes.position`), `allocSharedRef` stores the payload verbatim -- so
|
|
108
|
+
// the mesh POD must carry its own `.aabb` for the cull / pick path to read it.
|
|
109
|
+
const meshAsset: MeshAsset = { ...buildGlyphMeshAsset(layout), aabb };
|
|
110
|
+
const handle = world.allocSharedRef<'MeshAsset', MeshAsset>('MeshAsset', meshAsset);
|
|
111
|
+
return ok({ handle, aabb });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The 8 corners of a cube centered at the anchor with half-side = radius,
|
|
116
|
+
* packed as a flat `position` attribute (x,y,z per corner). `computeAABB`
|
|
117
|
+
* reduces this to the conservative cube AABB (D-5). For an empty layout
|
|
118
|
+
* (radius 0) all corners collapse to the origin, yielding a zero-volume box
|
|
119
|
+
* that pick treats as a point miss -- consistent with the empty-string path.
|
|
120
|
+
*/
|
|
121
|
+
function cubeCornerAttributes(radius: number): { position: Float32Array } {
|
|
122
|
+
const r = radius;
|
|
123
|
+
return {
|
|
124
|
+
position: Float32Array.of(
|
|
125
|
+
-r,
|
|
126
|
+
-r,
|
|
127
|
+
-r,
|
|
128
|
+
r,
|
|
129
|
+
-r,
|
|
130
|
+
-r,
|
|
131
|
+
r,
|
|
132
|
+
r,
|
|
133
|
+
-r,
|
|
134
|
+
-r,
|
|
135
|
+
r,
|
|
136
|
+
-r,
|
|
137
|
+
-r,
|
|
138
|
+
-r,
|
|
139
|
+
r,
|
|
140
|
+
r,
|
|
141
|
+
-r,
|
|
142
|
+
r,
|
|
143
|
+
r,
|
|
144
|
+
r,
|
|
145
|
+
r,
|
|
146
|
+
-r,
|
|
147
|
+
r,
|
|
148
|
+
r,
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras -- public barrel.
|
|
2
|
+
//
|
|
3
|
+
// feat-20260705-runtime-tier2-decomposition M3: pure-logic graphics-adjacent
|
|
4
|
+
// modules extracted from @forgeax/engine-runtime. System entry points
|
|
5
|
+
// (tilemapChunkExtractSystem / glyphTextLayoutSystem) stay in runtime and
|
|
6
|
+
// import from here -- see README.md for the package boundary declaration.
|
|
7
|
+
|
|
8
|
+
// ─── glyph text layout (feat-20260531-world-space-msdf-text-rendering) ─────
|
|
9
|
+
export {
|
|
10
|
+
FONT_CONCURRENCY_LIMIT,
|
|
11
|
+
type GlyphLayoutResult,
|
|
12
|
+
layoutGlyphText,
|
|
13
|
+
resetFontConcurrency,
|
|
14
|
+
trackFontConcurrency,
|
|
15
|
+
VERTEX_OFFSET,
|
|
16
|
+
} from './glyph-layout';
|
|
17
|
+
export {
|
|
18
|
+
bakeGlyphMesh,
|
|
19
|
+
buildGlyphMeshAsset,
|
|
20
|
+
conservativeCubeAabb,
|
|
21
|
+
type GlyphMeshBakeResult,
|
|
22
|
+
} from './glyph-mesh-bake';
|
|
23
|
+
|
|
24
|
+
// ─── tile-bits SSOT (feat-20260608-tilemap-object-layer-rendering) ─────────
|
|
25
|
+
export { decodeTileBits, encodeTileBits } from './tile-bits';
|
|
26
|
+
export { tilesetContribution } from './tileset-decoder';
|
|
27
|
+
// ─── VideoElementProvider host bridge ─────────────────────────────────────
|
|
28
|
+
export {
|
|
29
|
+
VIDEO_ELEMENT_PROVIDER_KEY,
|
|
30
|
+
type VideoElementProvider,
|
|
31
|
+
} from './video-element-provider';
|
|
32
|
+
export { videoContribution, videoLoader } from './video-loader';
|
|
33
|
+
// ─── VideoPlayer component ────────────────────────────────────────────────
|
|
34
|
+
export { VideoPlayer } from './video-player';
|
|
35
|
+
// ─── video high-perf upload capability probe ──────────────────────────────
|
|
36
|
+
export { probeVideoHighPerfUpload } from './video-player-system';
|
package/src/tile-bits.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras - tile-bits (Tiled .tmj wire-format helpers).
|
|
2
|
+
//
|
|
3
|
+
// Tiled .tmj packs each cell as a uint32 where the high 4 bits encode flip /
|
|
4
|
+
// rotation slots and the low 28 bits encode the 1-indexed tile id (id 0 means
|
|
5
|
+
// "empty cell"). The wire layout, from MSB to LSB:
|
|
6
|
+
//
|
|
7
|
+
// bit 31 flipHorizontal
|
|
8
|
+
// bit 30 flipVertical
|
|
9
|
+
// bit 29 flipDiagonal (== 90deg CW rotation in Tiled semantics)
|
|
10
|
+
// bit 28 flipHex120 (Tiled hex 120deg flip; preserved for fidelity,
|
|
11
|
+
// unused by the orthogonal renderer in M0)
|
|
12
|
+
// bits 0..27 tileId (0..0x0FFFFFFF; 0 sentinel = empty)
|
|
13
|
+
//
|
|
14
|
+
// Anchors: requirements integration points (engine-runtime tile-bits SSOT);
|
|
15
|
+
// plan-strategy §M0 targetFiles (tile-bits.ts); feat-20260604 D-2 wire
|
|
16
|
+
// compatibility lock.
|
|
17
|
+
//
|
|
18
|
+
// charter mapping: F1 (single-import barrel — encode/decode pair lives at
|
|
19
|
+
// `@forgeax/engine-runtime/tile-bits`), P3 (overflow / negative / non-integer
|
|
20
|
+
// tile id surface as RangeError, never silently clamp), P4 (encode and decode
|
|
21
|
+
// share the same bit layout and are inverse functions over the supported
|
|
22
|
+
// 0..0x0FFFFFFF tile id range).
|
|
23
|
+
|
|
24
|
+
const TILE_ID_MAX = 0x0fffffff;
|
|
25
|
+
const FLIP_H_BIT = 1 << 31;
|
|
26
|
+
const FLIP_V_BIT = 1 << 30;
|
|
27
|
+
const FLIP_D_BIT = 1 << 29;
|
|
28
|
+
const FLIP_HEX120_BIT = 1 << 28;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pack a Tiled .tmj cell into its u32 wire form.
|
|
32
|
+
*
|
|
33
|
+
* @param tileId 1-indexed tile id in [0, 0x0FFFFFFF]; `0` is the empty-cell
|
|
34
|
+
* sentinel that `TileLayer.tiles` carries downstream. `1` is the first real
|
|
35
|
+
* tile entry index into `TilesetAsset.tiles[0]`.
|
|
36
|
+
* @throws RangeError when `tileId` is non-integer, negative, or > 0x0FFFFFFF.
|
|
37
|
+
*/
|
|
38
|
+
export function encodeTileBits(
|
|
39
|
+
tileId: number,
|
|
40
|
+
flipH: boolean,
|
|
41
|
+
flipV: boolean,
|
|
42
|
+
flipDiagonal: boolean,
|
|
43
|
+
flipHex120: boolean,
|
|
44
|
+
): number {
|
|
45
|
+
if (!Number.isInteger(tileId) || tileId < 0 || tileId > TILE_ID_MAX) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
`encodeTileBits: tileId must be an integer in [0, ${TILE_ID_MAX}]; got ${tileId}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
let packed = tileId >>> 0;
|
|
51
|
+
if (flipH) packed |= FLIP_H_BIT;
|
|
52
|
+
if (flipV) packed |= FLIP_V_BIT;
|
|
53
|
+
if (flipDiagonal) packed |= FLIP_D_BIT;
|
|
54
|
+
if (flipHex120) packed |= FLIP_HEX120_BIT;
|
|
55
|
+
return packed >>> 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Unpack a Tiled .tmj cell from its u32 wire form into the structured shape
|
|
60
|
+
* consumed by `tilemap-chunk-extract-system.ts:spawnDerivedRenderEntities`.
|
|
61
|
+
*/
|
|
62
|
+
export function decodeTileBits(packed: number): {
|
|
63
|
+
readonly tileId: number;
|
|
64
|
+
readonly flipH: boolean;
|
|
65
|
+
readonly flipV: boolean;
|
|
66
|
+
readonly flipDiagonal: boolean;
|
|
67
|
+
readonly flipHex120: boolean;
|
|
68
|
+
} {
|
|
69
|
+
const u = packed >>> 0;
|
|
70
|
+
return {
|
|
71
|
+
tileId: u & TILE_ID_MAX,
|
|
72
|
+
flipH: (u & FLIP_H_BIT) !== 0,
|
|
73
|
+
flipV: (u & FLIP_V_BIT) !== 0,
|
|
74
|
+
flipDiagonal: (u & FLIP_D_BIT) !== 0,
|
|
75
|
+
flipHex120: (u & FLIP_HEX120_BIT) !== 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AssetDecoderContribution,
|
|
3
|
+
type AssetKind,
|
|
4
|
+
err,
|
|
5
|
+
ok,
|
|
6
|
+
type TilesetAsset,
|
|
7
|
+
} from '@forgeax/engine-types';
|
|
8
|
+
|
|
9
|
+
/** Tileset validation is owned here; no physics consumer exists yet. */
|
|
10
|
+
export const tilesetContribution: AssetDecoderContribution<TilesetAsset, 'tileset'> = {
|
|
11
|
+
kind: { kind: 'tileset' } as AssetKind<TilesetAsset, 'tileset'>,
|
|
12
|
+
consumer: 'tilemapChunkExtractSystem (physics consumer: missing evidence)',
|
|
13
|
+
decoder: {
|
|
14
|
+
async decode({ envelope }) {
|
|
15
|
+
const payload = envelope.payload as unknown;
|
|
16
|
+
if (payload !== null && typeof payload === 'object') {
|
|
17
|
+
const source = payload as Record<string, unknown>;
|
|
18
|
+
const rawAtlases = source.atlases;
|
|
19
|
+
const regions = source.regions;
|
|
20
|
+
const tiles = source.tiles;
|
|
21
|
+
const atlases = Array.isArray(rawAtlases)
|
|
22
|
+
? rawAtlases.map((value) =>
|
|
23
|
+
typeof value === 'number' && Number.isSafeInteger(value)
|
|
24
|
+
? envelope.refs[value]
|
|
25
|
+
: value,
|
|
26
|
+
)
|
|
27
|
+
: undefined;
|
|
28
|
+
if (
|
|
29
|
+
source.kind === 'tileset' &&
|
|
30
|
+
atlases !== undefined &&
|
|
31
|
+
atlases.length > 0 &&
|
|
32
|
+
atlases.every(
|
|
33
|
+
(atlas): atlas is string => typeof atlas === 'string' && atlas.length > 0,
|
|
34
|
+
) &&
|
|
35
|
+
Array.isArray(regions) &&
|
|
36
|
+
regions.length > 0 &&
|
|
37
|
+
Array.isArray(tiles) &&
|
|
38
|
+
tiles.every(
|
|
39
|
+
(tile) =>
|
|
40
|
+
tile !== null &&
|
|
41
|
+
typeof tile === 'object' &&
|
|
42
|
+
typeof (tile as { regionIndex?: unknown }).regionIndex === 'number' &&
|
|
43
|
+
(tile as { regionIndex: number }).regionIndex >= 0 &&
|
|
44
|
+
(tile as { regionIndex: number }).regionIndex < regions.length,
|
|
45
|
+
)
|
|
46
|
+
) {
|
|
47
|
+
return ok({
|
|
48
|
+
...source,
|
|
49
|
+
kind: 'tileset',
|
|
50
|
+
atlases,
|
|
51
|
+
regions,
|
|
52
|
+
tiles,
|
|
53
|
+
} as unknown as TilesetAsset);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return err({
|
|
57
|
+
code: 'asset-package-invalid',
|
|
58
|
+
expected: 'a tileset with atlas GUIDs and in-range tile regions',
|
|
59
|
+
hint: 'repair the tileset regions; physics consumption is not installed by this owner',
|
|
60
|
+
detail: { guid: envelope.guid, reason: 'tileset owner validation failed' },
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras — VideoElementProvider contract + Resource key
|
|
2
|
+
// (feat-20260623-world-space-video-asset M3 / w9).
|
|
3
|
+
//
|
|
4
|
+
// D-1: the channel by which a host HTMLVideoElement reaches the engine is a
|
|
5
|
+
// host-registered `VideoElementProvider` stored as a World Resource. The host
|
|
6
|
+
// implements this interface, owns the `<video>` DOM lifecycle (create / set
|
|
7
|
+
// src / autoplay / mute / dispose) single-sidedly, and registers it via
|
|
8
|
+
// `world.insertResource(VIDEO_ELEMENT_PROVIDER_KEY, provider)`. The engine's
|
|
9
|
+
// per-frame record stage (`render-system-record.ts` `videoTextureView`) reads
|
|
10
|
+
// it back each draw via `world.getResource(VIDEO_ELEMENT_PROVIDER_KEY)` and asks
|
|
11
|
+
// for the element to sample — it NEVER constructs an HTMLVideoElement, sets
|
|
12
|
+
// `.src`, or touches the DOM (requirements constraint: HTMLVideoElement is
|
|
13
|
+
// host-provided). There is no separate ECS "video player system" to register.
|
|
14
|
+
//
|
|
15
|
+
// Why a World Resource (not an ECS field): the ECS schema vocab is closed
|
|
16
|
+
// (component.ts:315-333) and admits no opaque/object field type, so an
|
|
17
|
+
// HTMLVideoElement reference cannot live inside a component. A World Resource
|
|
18
|
+
// is the typed singleton channel for host-owned services — the same shape used
|
|
19
|
+
// by animation-domain lookups / TransparentSortConfig (research Finding 5,
|
|
20
|
+
// plan-strategy D-1; plan-decisions F-3 correction: this uses the REAL World
|
|
21
|
+
// Resource API insertResource/getResource, not audio's direct-parameter
|
|
22
|
+
// injection).
|
|
23
|
+
//
|
|
24
|
+
// This module is a pure contract: an interface plus a typed key constant, no
|
|
25
|
+
// runtime behavior. Behavior is exercised by w10/w11 via a mock provider
|
|
26
|
+
// (the host's real implementation lives in the M5 demo, w20).
|
|
27
|
+
|
|
28
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
29
|
+
import type { Handle } from '@forgeax/engine-types';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resource key under which the host inserts its {@link VideoElementProvider}.
|
|
33
|
+
*
|
|
34
|
+
* Consumers import this constant rather than the bare string so a typo
|
|
35
|
+
* degrades to an import error rather than a silent missing-resource at runtime
|
|
36
|
+
* (charter P3). Naming mirrors the animation-domain lookup boundary /
|
|
37
|
+
* `TRANSPARENT_SORT_CONFIG_KEY`.
|
|
38
|
+
*
|
|
39
|
+
* @example Host registers its provider once per World:
|
|
40
|
+
* world.insertResource(VIDEO_ELEMENT_PROVIDER_KEY, myProvider);
|
|
41
|
+
* // ...the record stage reads it back each renderer.draw...
|
|
42
|
+
*/
|
|
43
|
+
export const VIDEO_ELEMENT_PROVIDER_KEY = 'VideoElementProvider' as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Host-implemented bridge from a VideoPlayer entity (+ its `clip` handle) to
|
|
47
|
+
* the host-owned HTMLVideoElement the engine samples each frame.
|
|
48
|
+
*
|
|
49
|
+
* The engine calls {@link getElement} during the video tick; the host returns
|
|
50
|
+
* the element it owns for that clip, or `undefined` when no element is
|
|
51
|
+
* available yet (clip not loaded / metadata pending / host has none). The
|
|
52
|
+
* engine treats `undefined` as "no source this frame" and routes through the
|
|
53
|
+
* structured failure / degrade path (AC-10) rather than sampling garbage.
|
|
54
|
+
*
|
|
55
|
+
* The engine NEVER mutates the returned element — it only reads it for the
|
|
56
|
+
* per-frame `copyExternalImageToTexture` upload (M4). DOM lifecycle is the
|
|
57
|
+
* host's sole responsibility (D-1).
|
|
58
|
+
*/
|
|
59
|
+
export interface VideoElementProvider {
|
|
60
|
+
/**
|
|
61
|
+
* Return the host-owned HTMLVideoElement for the given entity + clip, or
|
|
62
|
+
* `undefined` when none is available this frame.
|
|
63
|
+
*
|
|
64
|
+
* @param entity - the VideoPlayer entity requesting its element.
|
|
65
|
+
* @param clipHandle - the entity's `VideoPlayer.clip` handle
|
|
66
|
+
* (`Handle<'VideoAsset','shared'>`).
|
|
67
|
+
*/
|
|
68
|
+
getElement(
|
|
69
|
+
entity: EntityHandle,
|
|
70
|
+
clipHandle: Handle<'VideoAsset', 'shared'>,
|
|
71
|
+
): HTMLVideoElement | undefined;
|
|
72
|
+
}
|