@geastack/native-webgl-angle 0.1.11 → 0.1.12

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.
@@ -13,5 +13,33 @@ export interface GlyphAtlas {
13
13
  first: number
14
14
  glyphs: number[][]
15
15
  kerning: number[][]
16
+ /** The alpha bitmap is raw DEFLATE (zlib deflateRawSync) before base64. */
17
+ alphaPacked: boolean
16
18
  alphaBase64: string[]
17
19
  }
20
+
21
+ /**
22
+ * Metric rows from their packed spelling, `width` comma-separated numbers per
23
+ * row. A generated atlas writes its glyph and kerning tables this way because a
24
+ * nested array literal compiles to code building every row -- 470 KB of it for
25
+ * one font's 1078 kerning pairs -- while a string is plain read-only data.
26
+ */
27
+ /**
28
+ * The `alphaBase64` of an atlas whose bitmap the build does not carry
29
+ * (native-text.ts reads it from setAtlasAlphaSource instead). One typed array
30
+ * rather than a `[]` literal per atlas: a bundler inlines the literal, and an
31
+ * empty literal says nothing about its element type.
32
+ */
33
+ export const ALPHA_ELSEWHERE: string[] = []
34
+
35
+ export function unpackRows(width: number, packed: string): number[][] {
36
+ const rows: number[][] = []
37
+ if (packed.length === 0) return rows
38
+ const fields = packed.split(',')
39
+ for (let i = 0; i + width <= fields.length; i += width) {
40
+ const row: number[] = []
41
+ for (let k = 0; k < width; k++) row.push(Number(fields[i + k]))
42
+ rows.push(row)
43
+ }
44
+ return rows
45
+ }
@@ -6,15 +6,18 @@
6
6
  import { BufferAttribute } from 'three/src/core/BufferAttribute.js'
7
7
  import { BufferGeometry } from 'three/src/core/BufferGeometry.js'
8
8
  import { DataTexture } from 'three/src/textures/DataTexture.js'
9
+ import type { Texture } from 'three/src/textures/Texture.js'
9
10
  import {
10
11
  ClampToEdgeWrapping,
11
12
  LinearFilter,
12
13
  NoColorSpace,
13
- RGBAFormat,
14
+ RedFormat,
15
+ RGFormat,
14
16
  UnsignedByteType,
15
17
  UVMapping,
16
18
  } from 'three/src/constants.js'
17
19
  import type { GlyphAtlas } from './native-text-types'
20
+ import { base64Bytes, inflateRaw } from '../inflate'
18
21
  export interface LaidOutText {
19
22
  geometry: BufferGeometry
20
23
  positions: Float32Array
@@ -30,66 +33,73 @@ export interface LaidOutText {
30
33
  const textureCacheKeys: string[] = []
31
34
  const textureCacheValues: DataTexture[] = []
32
35
 
33
- function base64Value(code: number): number {
34
- if (code >= 65 && code <= 90) return code - 65
35
- if (code >= 97 && code <= 122) return code - 71
36
- if (code >= 48 && code <= 57) return code + 4
37
- if (code === 43) return 62
38
- if (code === 47) return 63
39
- return 0
36
+ // Where an atlas's alpha bitmap comes from when the build carries none
37
+ // (`alphaBase64` is empty): a target can keep its atlases outside the app image
38
+ // -- the ESP-Mosaico on its SPI NAND -- and set this to read one by atlas name.
39
+ // The bytes are the bitmap as baked: raw DEFLATE when `alphaPacked`.
40
+ let atlasAlphaSource: (name: string) => Uint8Array = (name: string): Uint8Array => {
41
+ throw new Error(`text: no alpha bitmap for atlas ${name}: this build carries none and no source is set`)
40
42
  }
41
43
 
42
- // Decode the base64 alpha bitmap into an RGBA texture (white RGB, glyph
43
- // alpha). Built once per atlas and shared by every text instance.
44
+ export function setAtlasAlphaSource(source: (name: string) => Uint8Array): void {
45
+ atlasAlphaSource = source
46
+ }
47
+
48
+ // A renderer that can sample one byte per texel as both red and green (gea-threejs:
49
+ // TextureUserData.redAsGreen) keeps an atlas at one byte per texel instead of two,
50
+ // and the bitmap read from the source is the texture's own storage (no copy).
51
+ let singleByteAtlases = false
52
+
53
+ export function useSingleByteAtlases(): void {
54
+ singleByteAtlases = true
55
+ }
56
+
57
+ // Decode the alpha bitmap into the glyph coverage texture, built once per atlas
58
+ // and shared by every text instance. It is the material's alphaMap, which
59
+ // reads green, so the texture is red-green (r = g = coverage): two bytes per
60
+ // texel where a white RGBA map took four, and a renderer that keeps byte
61
+ // images at their own size (gea-threejs) holds half of what it did. The
62
+ // bytes are dropped once uploaded -- nothing re-uploads an atlas.
44
63
  export function atlasTexture(atlas: GlyphAtlas): DataTexture {
45
64
  for (let i = 0; i < textureCacheKeys.length; i++) {
46
65
  if (textureCacheKeys[i] === atlas.name) return textureCacheValues[i]
47
66
  }
48
67
  const total = atlas.atlasWidth * atlas.atlasHeight
49
- const rgba = new Uint8Array(total * 4)
50
- let out = 0
51
- for (let c = 0; c < atlas.alphaBase64.length; c++) {
52
- // Do not route through atob's "binary string". The native runtime stores
53
- // strings as UTF-8, so bytes >= 0x80 would be interpreted as Unicode when
54
- // charCodeAt reads the result, corrupting the alpha atlas. Decode the ASCII
55
- // base64 directly into the typed array instead.
56
- const chunk = atlas.alphaBase64[c]
57
- for (let i = 0; i < chunk.length; i += 4) {
58
- const code2 = chunk.charCodeAt(i + 2)
59
- const code3 = chunk.charCodeAt(i + 3)
60
- const v0 = base64Value(chunk.charCodeAt(i))
61
- const v1 = base64Value(chunk.charCodeAt(i + 1))
62
- const v2 = base64Value(code2)
63
- const v3 = base64Value(code3)
64
- const a0 = (v0 << 2) | (v1 >> 4)
65
- rgba[out] = 255
66
- rgba[out + 1] = 255
67
- rgba[out + 2] = 255
68
- rgba[out + 3] = a0
69
- out += 4
70
- if (code2 !== 61) {
71
- const a1 = ((v1 & 15) << 4) | (v2 >> 2)
72
- rgba[out] = 255
73
- rgba[out + 1] = 255
74
- rgba[out + 2] = 255
75
- rgba[out + 3] = a1
76
- out += 4
77
- }
78
- if (code3 !== 61) {
79
- const a2 = ((v2 & 3) << 6) | v3
80
- rgba[out] = 255
81
- rgba[out + 1] = 255
82
- rgba[out + 2] = 255
83
- rgba[out + 3] = a2
84
- out += 4
85
- }
86
- }
68
+ const encoded = atlas.alphaBase64.length === 0 ? atlasAlphaSource(atlas.name) : base64Bytes(atlas.alphaBase64)
69
+ const alpha = atlas.alphaPacked ? inflateRaw(encoded, total) : encoded
70
+ if (singleByteAtlases) {
71
+ const single = new DataTexture(
72
+ alpha,
73
+ atlas.atlasWidth,
74
+ atlas.atlasHeight,
75
+ RedFormat,
76
+ UnsignedByteType,
77
+ UVMapping,
78
+ ClampToEdgeWrapping,
79
+ ClampToEdgeWrapping,
80
+ LinearFilter,
81
+ LinearFilter,
82
+ 1,
83
+ NoColorSpace,
84
+ )
85
+ single.userData.redAsGreen = true
86
+ single.unpackAlignment = 1
87
+ single.onUpdate = releaseAtlasBytes
88
+ single.needsUpdate = true
89
+ textureCacheKeys.push(atlas.name)
90
+ textureCacheValues.push(single)
91
+ return single
92
+ }
93
+ const coverage = new Uint8Array(total * 2)
94
+ for (let i = 0; i < total; i++) {
95
+ coverage[i * 2] = alpha[i]
96
+ coverage[i * 2 + 1] = alpha[i]
87
97
  }
88
98
  const tex = new DataTexture(
89
- rgba,
99
+ coverage,
90
100
  atlas.atlasWidth,
91
101
  atlas.atlasHeight,
92
- RGBAFormat,
102
+ RGFormat,
93
103
  UnsignedByteType,
94
104
  UVMapping,
95
105
  ClampToEdgeWrapping,
@@ -99,12 +109,22 @@ export function atlasTexture(atlas: GlyphAtlas): DataTexture {
99
109
  1,
100
110
  NoColorSpace,
101
111
  )
112
+ // Rows of width * 2 bytes: tightly packed for any even width.
113
+ tex.unpackAlignment = 2
114
+ tex.onUpdate = releaseAtlasBytes
102
115
  tex.needsUpdate = true
103
116
  textureCacheKeys.push(atlas.name)
104
117
  textureCacheValues.push(tex)
105
118
  return tex
106
119
  }
107
120
 
121
+
122
+ function releaseAtlasBytes(texture: Texture): void {
123
+ if (!(texture instanceof DataTexture)) return
124
+ const image = texture.image
125
+ if (image !== null) image.data = new Uint8Array(0)
126
+ }
127
+
108
128
  const kerningCacheKeys: string[] = []
109
129
  const kerningCacheValues: Map<number, number>[] = []
110
130
 
@@ -121,6 +141,90 @@ function kerningTable(atlas: GlyphAtlas): Map<number, number> {
121
141
  return table
122
142
  }
123
143
 
144
+ // layoutText's quads written into existing position (12 per quad) and uv
145
+ // (8 per quad) arrays, when `text` lays out to exactly as many quads as they
146
+ // hold: a score or FPS readout changing digits reuses its geometry instead of
147
+ // building a new one. Returns the laid-out width, or -1 (nothing written in a
148
+ // way the caller may keep) when the quad count differs.
149
+ export function layoutTextInPlace(
150
+ atlas: GlyphAtlas,
151
+ text: string,
152
+ fontSize: number,
153
+ letterSpacing: number,
154
+ positions: Float32Array,
155
+ uvs: Float32Array,
156
+ ): number {
157
+ const quads = positions.length / 12
158
+ if (uvs.length !== quads * 8) return -1
159
+ let n = 0
160
+ for (let i = 0; i < text.length; i++) {
161
+ const code = text.charCodeAt(i)
162
+ if (code === 10) return -1
163
+ const index = code - atlas.first
164
+ if (index < 0 || index >= atlas.glyphs.length) continue
165
+ const g = atlas.glyphs[index]
166
+ if (g[2] > g[0] && g[3] > g[1]) n++
167
+ }
168
+ if (n !== quads) return -1
169
+ const scale = fontSize / atlas.pixelHeight
170
+ const kern = kerningTable(atlas)
171
+ const invW = 1 / atlas.atlasWidth
172
+ const invH = 1 / atlas.atlasHeight
173
+ let penX = 0
174
+ let prev = 0
175
+ let q = 0
176
+ for (let i = 0; i < text.length; i++) {
177
+ const code = text.charCodeAt(i)
178
+ const index = code - atlas.first
179
+ if (index < 0 || index >= atlas.glyphs.length) continue
180
+ if (prev !== 0) {
181
+ const k = kern.get(prev * 1024 + code)
182
+ if (k !== undefined) penX += k * scale
183
+ }
184
+ const g = atlas.glyphs[index]
185
+ const gx0 = g[0]
186
+ const gy0 = g[1]
187
+ const gx1 = g[2]
188
+ const gy1 = g[3]
189
+ if (gx1 > gx0 && gy1 > gy0) {
190
+ const x0 = penX + g[4] * scale
191
+ const y1 = -g[5] * scale
192
+ const x1 = x0 + (gx1 - gx0) * scale
193
+ const y0 = y1 - (gy1 - gy0) * scale
194
+ const p = q * 12
195
+ positions[p] = x0
196
+ positions[p + 1] = y0
197
+ positions[p + 2] = 0
198
+ positions[p + 3] = x1
199
+ positions[p + 4] = y0
200
+ positions[p + 5] = 0
201
+ positions[p + 6] = x1
202
+ positions[p + 7] = y1
203
+ positions[p + 8] = 0
204
+ positions[p + 9] = x0
205
+ positions[p + 10] = y1
206
+ positions[p + 11] = 0
207
+ const u0 = gx0 * invW
208
+ const u1 = gx1 * invW
209
+ const v0 = gy1 * invH
210
+ const v1 = gy0 * invH
211
+ const t = q * 8
212
+ uvs[t] = u0
213
+ uvs[t + 1] = v0
214
+ uvs[t + 2] = u1
215
+ uvs[t + 3] = v0
216
+ uvs[t + 4] = u1
217
+ uvs[t + 5] = v1
218
+ uvs[t + 6] = u0
219
+ uvs[t + 7] = v1
220
+ q++
221
+ }
222
+ penX += g[6] * scale + letterSpacing * fontSize
223
+ prev = code
224
+ }
225
+ return penX > 0 ? penX : 0
226
+ }
227
+
124
228
  // Build quad geometry for `text` at `fontSize` world units per em.
125
229
  // letterSpacing is in em units (troika convention). Multi-line via '\n'.
126
230
  // The origin is the LEFT edge of the FIRST baseline; the caller applies
@@ -22,7 +22,7 @@ import { BufferGeometry } from 'three/src/core/BufferGeometry.js'
22
22
  import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js'
23
23
  import { Object3D } from 'three/src/core/Object3D.js'
24
24
  import type { Texture } from 'three/src/textures/Texture.js'
25
- import { atlasTexture, layoutText } from './text/native-text'
25
+ import { atlasTexture, layoutText, layoutTextInPlace } from './text/native-text'
26
26
  import type { GlyphAtlas } from './text/native-text-types'
27
27
  import { sansAtlas } from './text/atlas-sans'
28
28
  import { serifAtlas } from './text/atlas-serif'
@@ -164,7 +164,13 @@ export class TroikaText extends Object3D {
164
164
  return
165
165
  }
166
166
  const atlas = atlasForFont(this.font)
167
+ const sameShape = this.inPlace(atlas, atlasKey)
167
168
  this.layoutKey = nextLayoutKey
169
+ if (sameShape) {
170
+ setMaterialHex(this.material, this.color)
171
+ this.material.opacity = this.fillOpacity
172
+ return
173
+ }
168
174
  const laidOut = layoutText(atlas, this.text, this.fontSize, this.letterSpacing)
169
175
  // Anchor offsets: troika's anchorX left|center|right and anchorY
170
176
  // top|middle|bottom (the subset the app uses). The layout origin is the
@@ -227,10 +233,11 @@ export class TroikaText extends Object3D {
227
233
  }
228
234
  // Keep both sides of the identity comparison on the shared Texture
229
235
  // carrier. Otherwise a derived DataTexture local can be incorrectly
230
- // treated as disjoint from Material.map during native constant folding.
236
+ // treated as disjoint from Material.alphaMap during native constant folding.
237
+ this.laidAtlas = atlasKey
231
238
  const nextMap: Texture = atlasTexture(atlas)
232
- if (this.material.map !== nextMap) {
233
- this.material.map = nextMap
239
+ if (this.material.alphaMap !== nextMap) {
240
+ this.material.alphaMap = nextMap
234
241
  // needsUpdate recompiles the shader program; only pay that when the
235
242
  // map binding actually changed, not on every per-frame sync().
236
243
  this.material.needsUpdate = true
@@ -239,6 +246,42 @@ export class TroikaText extends Object3D {
239
246
  this.material.opacity = this.fillOpacity
240
247
  }
241
248
 
249
+ // Only the text changed and lays out to as many quads as the current
250
+ // geometry holds (a counter changing digits): rewrite its positions and
251
+ // uvs in place. The index pattern of n quads does not depend on the glyphs.
252
+ private laidAtlas = ''
253
+ private inPlace(atlas: GlyphAtlas, atlasKey: string): boolean {
254
+ if (atlasKey !== this.laidAtlas) return false
255
+ const geometry: BufferGeometry = this.mesh.geometry
256
+ const position = geometry.getAttribute('position')
257
+ const uv = geometry.getAttribute('uv')
258
+ if (!(position instanceof BufferAttribute) || !(uv instanceof BufferAttribute)) return false
259
+ const positions = position.array
260
+ const uvs = uv.array
261
+ if (!(positions instanceof Float32Array) || !(uvs instanceof Float32Array) || positions.length === 0) return false
262
+ const width = layoutTextInPlace(atlas, this.text, this.fontSize, this.letterSpacing, positions, uvs)
263
+ if (width < 0) return false
264
+ const scale = this.fontSize / atlas.pixelHeight
265
+ const ascent = atlas.ascent * scale
266
+ const descent = atlas.descent * scale
267
+ let offsetX = 0
268
+ if (this.anchorX === 'center') offsetX = -width / 2
269
+ else if (this.anchorX === 'right') offsetX = -width
270
+ let offsetY = 0
271
+ if (this.anchorY === 'middle') offsetY = -(ascent + descent) / 2
272
+ else if (this.anchorY === 'top') offsetY = -ascent
273
+ else if (this.anchorY === 'bottom') offsetY = -descent
274
+ for (let i = 0; i < positions.length; i += 3) {
275
+ positions[i] += offsetX
276
+ positions[i + 1] += offsetY
277
+ }
278
+ position.needsUpdate = true
279
+ uv.needsUpdate = true
280
+ geometry.boundingSphere = null
281
+ geometry.boundingBox = null
282
+ return true
283
+ }
284
+
242
285
  dispose(): void {
243
286
  if (this.mesh.geometry) this.mesh.geometry.dispose()
244
287
  this.material.dispose()
@@ -246,3 +289,5 @@ export class TroikaText extends Object3D {
246
289
  }
247
290
 
248
291
  export { TroikaText as Text }
292
+ // A target that keeps atlas bitmaps outside its image (see native-text.ts).
293
+ export { setAtlasAlphaSource, useSingleByteAtlases } from './text/native-text'
package/NOTICE DELETED
@@ -1,4 +0,0 @@
1
- GeaStack native-webgl-angle
2
- Copyright 2026 GeaStack
3
-
4
- Licensed under the Apache License, Version 2.0; see LICENSE.