@geastack/native-webgl-angle 0.1.6
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 +184 -0
- package/geatsc-plugin-batched-probe.mjs +98 -0
- package/geatsc-plugin-instancing.mjs +160 -0
- package/geatsc-plugin-uniforms.mjs +1955 -0
- package/geatsc-plugin-uploads.mjs +29 -0
- package/geatsc-plugin.mjs +10275 -0
- package/native/angle_webgl_host.mm +2687 -0
- package/native/audio_host.mm +857 -0
- package/native/audio_uwp.h +116 -0
- package/package.json +70 -0
- package/src/gea-native-types.d.ts +4 -0
- package/src/nativeAudioHost.ts +296 -0
- package/src/nativeThreeUtils.ts +11 -0
- package/src/nativeWebGL.ts +1211 -0
- package/src/nativeWebGLAnimation.ts +13 -0
- package/src/nativeWebGLHost.ts +333 -0
- package/src/nativeWebXRManager.ts +48 -0
- package/src/text/atlas-sans.ts +195 -0
- package/src/text/atlas-serif-italic.ts +195 -0
- package/src/text/atlas-serif.ts +195 -0
- package/src/text/native-text-types.ts +17 -0
- package/src/text/native-text.ts +203 -0
- package/src/troika-three-text.ts +248 -0
- package/test/auto-instanced-render-scene.ts +66 -0
- package/test/batched-render-scene.ts +58 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shape of a baked glyph atlas (native-webgl-angle/tools/bake-font-atlas.cpp
|
|
2
|
+
// via gen-troika-atlas.mjs). Glyph rows are [x0, y0, x1, y1, xoff, yoff,
|
|
3
|
+
// xadvance] in atlas pixels at `pixelHeight`; kerning rows are
|
|
4
|
+
// [codepointA, codepointB, advancePx].
|
|
5
|
+
export interface GlyphAtlas {
|
|
6
|
+
name: string
|
|
7
|
+
atlasWidth: number
|
|
8
|
+
atlasHeight: number
|
|
9
|
+
pixelHeight: number
|
|
10
|
+
ascent: number
|
|
11
|
+
descent: number
|
|
12
|
+
lineGap: number
|
|
13
|
+
first: number
|
|
14
|
+
glyphs: number[][]
|
|
15
|
+
kerning: number[][]
|
|
16
|
+
alphaBase64: string[]
|
|
17
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Native text rendering for the troika-three-text Text surface: baked alpha glyph
|
|
2
|
+
// atlases (stb_truetype, build-time) drawn as textured quads. This is the
|
|
3
|
+
// "engine stb_truetype -> GL atlas" path — it runs on
|
|
4
|
+
// any three-angle target (macOS/ANGLE, Windows) with zero host dependencies: the
|
|
5
|
+
// atlas rides in the compiled binary and uploads as a DataTexture.
|
|
6
|
+
import { BufferAttribute } from 'three/src/core/BufferAttribute.js'
|
|
7
|
+
import { BufferGeometry } from 'three/src/core/BufferGeometry.js'
|
|
8
|
+
import { DataTexture } from 'three/src/textures/DataTexture.js'
|
|
9
|
+
import {
|
|
10
|
+
ClampToEdgeWrapping,
|
|
11
|
+
LinearFilter,
|
|
12
|
+
NoColorSpace,
|
|
13
|
+
RGBAFormat,
|
|
14
|
+
UnsignedByteType,
|
|
15
|
+
UVMapping,
|
|
16
|
+
} from 'three/src/constants.js'
|
|
17
|
+
import type { GlyphAtlas } from './native-text-types'
|
|
18
|
+
export interface LaidOutText {
|
|
19
|
+
geometry: BufferGeometry
|
|
20
|
+
positions: Float32Array
|
|
21
|
+
width: number
|
|
22
|
+
ascent: number
|
|
23
|
+
descent: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Caches keyed by the atlas NAME: GlyphAtlas is an interface (by-value
|
|
27
|
+
// struct under geatsc), so identity comparison on module reads never hits —
|
|
28
|
+
// which would re-decode and re-upload the 1MB texture on every sync
|
|
29
|
+
// (measured: 0.2 fps). String keys are stable across copies.
|
|
30
|
+
const textureCacheKeys: string[] = []
|
|
31
|
+
const textureCacheValues: DataTexture[] = []
|
|
32
|
+
|
|
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
|
|
40
|
+
}
|
|
41
|
+
|
|
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 atlasTexture(atlas: GlyphAtlas): DataTexture {
|
|
45
|
+
for (let i = 0; i < textureCacheKeys.length; i++) {
|
|
46
|
+
if (textureCacheKeys[i] === atlas.name) return textureCacheValues[i]
|
|
47
|
+
}
|
|
48
|
+
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
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const tex = new DataTexture(
|
|
89
|
+
rgba,
|
|
90
|
+
atlas.atlasWidth,
|
|
91
|
+
atlas.atlasHeight,
|
|
92
|
+
RGBAFormat,
|
|
93
|
+
UnsignedByteType,
|
|
94
|
+
UVMapping,
|
|
95
|
+
ClampToEdgeWrapping,
|
|
96
|
+
ClampToEdgeWrapping,
|
|
97
|
+
LinearFilter,
|
|
98
|
+
LinearFilter,
|
|
99
|
+
1,
|
|
100
|
+
NoColorSpace,
|
|
101
|
+
)
|
|
102
|
+
tex.needsUpdate = true
|
|
103
|
+
textureCacheKeys.push(atlas.name)
|
|
104
|
+
textureCacheValues.push(tex)
|
|
105
|
+
return tex
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const kerningCacheKeys: string[] = []
|
|
109
|
+
const kerningCacheValues: Map<number, number>[] = []
|
|
110
|
+
|
|
111
|
+
function kerningTable(atlas: GlyphAtlas): Map<number, number> {
|
|
112
|
+
for (let i = 0; i < kerningCacheKeys.length; i++) {
|
|
113
|
+
if (kerningCacheKeys[i] === atlas.name) return kerningCacheValues[i]
|
|
114
|
+
}
|
|
115
|
+
const table = new Map<number, number>()
|
|
116
|
+
for (const row of atlas.kerning) {
|
|
117
|
+
table.set(row[0] * 1024 + row[1], row[2])
|
|
118
|
+
}
|
|
119
|
+
kerningCacheKeys.push(atlas.name)
|
|
120
|
+
kerningCacheValues.push(table)
|
|
121
|
+
return table
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build quad geometry for `text` at `fontSize` world units per em.
|
|
125
|
+
// letterSpacing is in em units (troika convention). Multi-line via '\n'.
|
|
126
|
+
// The origin is the LEFT edge of the FIRST baseline; the caller applies
|
|
127
|
+
// anchor offsets from the returned metrics.
|
|
128
|
+
export function layoutText(
|
|
129
|
+
atlas: GlyphAtlas,
|
|
130
|
+
text: string,
|
|
131
|
+
fontSize: number,
|
|
132
|
+
letterSpacing: number,
|
|
133
|
+
): LaidOutText {
|
|
134
|
+
const scale = fontSize / atlas.pixelHeight
|
|
135
|
+
const lineHeight = (atlas.ascent - atlas.descent + atlas.lineGap) * scale
|
|
136
|
+
const kern = kerningTable(atlas)
|
|
137
|
+
const positions: number[] = []
|
|
138
|
+
const uvs: number[] = []
|
|
139
|
+
const indices: number[] = []
|
|
140
|
+
let penX = 0
|
|
141
|
+
let penY = 0
|
|
142
|
+
let maxX = 0
|
|
143
|
+
let prev = 0
|
|
144
|
+
const invW = 1 / atlas.atlasWidth
|
|
145
|
+
const invH = 1 / atlas.atlasHeight
|
|
146
|
+
for (let i = 0; i < text.length; i++) {
|
|
147
|
+
const code = text.charCodeAt(i)
|
|
148
|
+
if (code === 10) {
|
|
149
|
+
if (penX > maxX) maxX = penX
|
|
150
|
+
penX = 0
|
|
151
|
+
penY -= lineHeight
|
|
152
|
+
prev = 0
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const index = code - atlas.first
|
|
156
|
+
if (index < 0 || index >= atlas.glyphs.length) continue
|
|
157
|
+
if (prev !== 0) {
|
|
158
|
+
const k = kern.get(prev * 1024 + code)
|
|
159
|
+
if (k !== undefined) penX += k * scale
|
|
160
|
+
}
|
|
161
|
+
const g = atlas.glyphs[index]
|
|
162
|
+
const gx0 = g[0]
|
|
163
|
+
const gy0 = g[1]
|
|
164
|
+
const gx1 = g[2]
|
|
165
|
+
const gy1 = g[3]
|
|
166
|
+
const xoff = g[4]
|
|
167
|
+
const yoff = g[5]
|
|
168
|
+
const xadvance = g[6]
|
|
169
|
+
if (gx1 > gx0 && gy1 > gy0) {
|
|
170
|
+
// yoff is the offset of the bitmap TOP from the baseline (negative up
|
|
171
|
+
// in stb's y-down convention); flip into three's y-up space.
|
|
172
|
+
const x0 = penX + xoff * scale
|
|
173
|
+
const y1 = penY - yoff * scale
|
|
174
|
+
const x1 = x0 + (gx1 - gx0) * scale
|
|
175
|
+
const y0 = y1 - (gy1 - gy0) * scale
|
|
176
|
+
const base = positions.length / 3
|
|
177
|
+
positions.push(x0, y0, 0, x1, y0, 0, x1, y1, 0, x0, y1, 0)
|
|
178
|
+
// DataTexture rows upload bottom-up (flipY=false): atlas row 0 (top of
|
|
179
|
+
// the baked image) lands at v=0, so v maps directly from atlas y.
|
|
180
|
+
const u0 = gx0 * invW
|
|
181
|
+
const u1 = gx1 * invW
|
|
182
|
+
const v0 = gy1 * invH
|
|
183
|
+
const v1 = gy0 * invH
|
|
184
|
+
uvs.push(u0, v0, u1, v0, u1, v1, u0, v1)
|
|
185
|
+
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
|
186
|
+
}
|
|
187
|
+
penX += xadvance * scale + letterSpacing * fontSize
|
|
188
|
+
prev = code
|
|
189
|
+
}
|
|
190
|
+
if (penX > maxX) maxX = penX
|
|
191
|
+
const positionArray = new Float32Array(positions)
|
|
192
|
+
const geometry = new BufferGeometry()
|
|
193
|
+
geometry.setAttribute('position', new BufferAttribute(positionArray, 3))
|
|
194
|
+
geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2))
|
|
195
|
+
geometry.setIndex(indices)
|
|
196
|
+
return {
|
|
197
|
+
geometry,
|
|
198
|
+
positions: positionArray,
|
|
199
|
+
width: maxX,
|
|
200
|
+
ascent: atlas.ascent * scale,
|
|
201
|
+
descent: atlas.descent * scale,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Native-target implementation of the `troika-three-text` Text surface.
|
|
2
|
+
//
|
|
3
|
+
// troika-three-text renders SDF glyphs via a Web Worker + runtime font parsing,
|
|
4
|
+
// neither of which is available on the native/ANGLE target. Browser builds use
|
|
5
|
+
// the upstream package; native target resolution selects this module, which renders the
|
|
6
|
+
// SAME property surface (text/font/fontSize/color/anchorX/anchorY/letterSpacing/
|
|
7
|
+
// fillOpacity + sync()) from build-time stb_truetype glyph atlases (see
|
|
8
|
+
// native-webgl-angle/tools/bake-font-atlas.cpp) drawn as textured quads.
|
|
9
|
+
//
|
|
10
|
+
// Font selection consults the atlases an app registered with
|
|
11
|
+
// registerFontAtlas(); the Playfair regular/italic and Inter atlases this
|
|
12
|
+
// package bakes are the defaults behind them.
|
|
13
|
+
//
|
|
14
|
+
// Import from the specific `three/src` path (NOT bare `three`): a bare-`three`
|
|
15
|
+
// import resolves to the pre-bundled `three/build/three.module.js`, which emits
|
|
16
|
+
// duplicate cross-module classes (Vector2__m2, "incomplete type") and drags the
|
|
17
|
+
// entire library into the compile graph.
|
|
18
|
+
import { DoubleSide } from 'three/src/constants.js'
|
|
19
|
+
import { Mesh } from 'three/src/objects/Mesh.js'
|
|
20
|
+
import { BufferAttribute } from 'three/src/core/BufferAttribute.js'
|
|
21
|
+
import { BufferGeometry } from 'three/src/core/BufferGeometry.js'
|
|
22
|
+
import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js'
|
|
23
|
+
import { Object3D } from 'three/src/core/Object3D.js'
|
|
24
|
+
import type { Texture } from 'three/src/textures/Texture.js'
|
|
25
|
+
import { atlasTexture, layoutText } from './text/native-text'
|
|
26
|
+
import type { GlyphAtlas } from './text/native-text-types'
|
|
27
|
+
import { sansAtlas } from './text/atlas-sans'
|
|
28
|
+
import { serifAtlas } from './text/atlas-serif'
|
|
29
|
+
import { serifItalicAtlas } from './text/atlas-serif-italic'
|
|
30
|
+
|
|
31
|
+
// The fonts belong to the app, not to this package. Selecting them by the
|
|
32
|
+
// names one app happens to use -- 'playfair', 'italic' -- meant every other app
|
|
33
|
+
// rendered its whole UI in the fallback sans while nothing reported a problem:
|
|
34
|
+
// the reference app's Unbounded display face silently became Inter. An app whose own
|
|
35
|
+
// fonts must render natively bakes them (tools/bake-font-atlas.cpp, then
|
|
36
|
+
// tools/gen-troika-atlas.mjs) and registers them from its native entry, before
|
|
37
|
+
// the first Text is constructed. The three below stay as this package's
|
|
38
|
+
// defaults so an app that registers nothing still draws.
|
|
39
|
+
interface RegisteredAtlas {
|
|
40
|
+
match: string
|
|
41
|
+
atlas: GlyphAtlas
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const registered: RegisteredAtlas[] = []
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map a substring of the app's `font` value (a bundler asset URL, so matching
|
|
48
|
+
* the file stem is enough) onto a baked atlas. Later registrations win over the
|
|
49
|
+
* built-in defaults but not over earlier ones, so registration order is the
|
|
50
|
+
* app's priority order.
|
|
51
|
+
*/
|
|
52
|
+
export function registerFontAtlas(match: string, atlas: GlyphAtlas): void {
|
|
53
|
+
registered.push({ match, atlas })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// One authority for "which atlas is this font", because sync() needs the
|
|
57
|
+
// identity for its layout key and the layout itself needs the atlas; resolving
|
|
58
|
+
// them separately is how the two could ever disagree.
|
|
59
|
+
function atlasNameForFont(font: string | null): string {
|
|
60
|
+
if (font === null) return 'sans'
|
|
61
|
+
for (let i = 0; i < registered.length; i++) {
|
|
62
|
+
const entry = registered[i]
|
|
63
|
+
if (entry !== undefined && font.indexOf(entry.match) >= 0) return entry.match
|
|
64
|
+
}
|
|
65
|
+
if (font.indexOf('italic') >= 0) return 'serif-italic'
|
|
66
|
+
if (font.indexOf('playfair') >= 0) return 'serif'
|
|
67
|
+
return 'sans'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function atlasForFont(font: string | null): GlyphAtlas {
|
|
71
|
+
const name = atlasNameForFont(font)
|
|
72
|
+
for (let i = 0; i < registered.length; i++) {
|
|
73
|
+
const entry = registered[i]
|
|
74
|
+
if (entry !== undefined && entry.match === name) return entry.atlas
|
|
75
|
+
}
|
|
76
|
+
if (name === 'serif-italic') return serifItalicAtlas
|
|
77
|
+
if (name === 'serif') return serifAtlas
|
|
78
|
+
return sansAtlas
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function srgbToLinear(value: number): number {
|
|
82
|
+
return value < 0.04045 ? value * 0.0773993808 : Math.pow(value * 0.9478672986 + 0.0521327014, 2.4)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setMaterialHex(material: MeshBasicMaterial, value: number): void {
|
|
86
|
+
material.color.r = srgbToLinear(((value >> 16) & 0xff) / 255)
|
|
87
|
+
material.color.g = srgbToLinear(((value >> 8) & 0xff) / 255)
|
|
88
|
+
material.color.b = srgbToLinear((value & 0xff) / 255)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// NOTE: the class must NOT be named `Text` — geatsc's gea host-shim maps any
|
|
92
|
+
// type named `Text` (the DOM global) to `gea::embedded::ui::NodeHandle`, so a
|
|
93
|
+
// class named `Text` would lower to a NodeHandle (no `.sync()`/`.visible`).
|
|
94
|
+
// Name it `TroikaText` and re-export as `Text` so the upstream package's
|
|
95
|
+
// import surface is unchanged while the class keeps its own native struct type.
|
|
96
|
+
export class TroikaText extends Object3D {
|
|
97
|
+
text = ''
|
|
98
|
+
font: string | null = null
|
|
99
|
+
fontSize = 1
|
|
100
|
+
color: number = 0xffffff
|
|
101
|
+
anchorX: number | string = 'center'
|
|
102
|
+
anchorY: number | string = 'middle'
|
|
103
|
+
letterSpacing = 0
|
|
104
|
+
fillOpacity = 1
|
|
105
|
+
depthOffset = 0
|
|
106
|
+
outlineWidth: number | string = 0
|
|
107
|
+
outlineColor: number | string = 0x000000
|
|
108
|
+
material: MeshBasicMaterial
|
|
109
|
+
private mesh: Mesh
|
|
110
|
+
// Layout fingerprint of the last geometry build. HUD callers (troika
|
|
111
|
+
// convention) call sync() every frame while pulsing fillOpacity/color;
|
|
112
|
+
// rebuilding glyph quads for that is pure waste — only rebuild when an
|
|
113
|
+
// input that changes GEOMETRY changed.
|
|
114
|
+
private layoutKey = '\u0000'
|
|
115
|
+
|
|
116
|
+
constructor() {
|
|
117
|
+
super()
|
|
118
|
+
this.material = new MeshBasicMaterial({
|
|
119
|
+
transparent: true,
|
|
120
|
+
depthWrite: false,
|
|
121
|
+
side: DoubleSide,
|
|
122
|
+
// Flat glyph quads never need Three's transparent double-sided two-pass
|
|
123
|
+
// path. Without this, Three flips `side` twice per label per frame,
|
|
124
|
+
// increments material.version, and rebuilds the program-parameter graph.
|
|
125
|
+
forceSinglePass: true
|
|
126
|
+
})
|
|
127
|
+
this.mesh = new Mesh(new BufferGeometry(), this.material)
|
|
128
|
+
// This mesh lives in the orthographic HUD pass. Its bounds are rebuilt
|
|
129
|
+
// whenever glyph geometry changes, and native Three can otherwise retain
|
|
130
|
+
// the empty constructor geometry's bounds until a later text update. HUD
|
|
131
|
+
// glyphs are screen-space by definition, so frustum culling is both
|
|
132
|
+
// unnecessary and capable of hiding labels that never change after mount.
|
|
133
|
+
this.mesh.frustumCulled = false
|
|
134
|
+
this.add(this.mesh)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// No callback param: troika's real sync(cb?) is async, but this native stub
|
|
138
|
+
// lays out synchronously, so every caller invokes sync() with no argument.
|
|
139
|
+
// Declaring an optional callback made the dynamic (duck-typed) call site in
|
|
140
|
+
// hud.ts lower the absent arg into a non-empty std::function that invokes a
|
|
141
|
+
// `missing` value ("Value is not callable"); omitting it removes that path.
|
|
142
|
+
sync(): void {
|
|
143
|
+
// Compute the cheap font identity first. GlyphAtlas is a large generated
|
|
144
|
+
// by-value struct under geatsc, so fetching it before the layout-key check
|
|
145
|
+
// copied the entire atlas even for paint-only syncs.
|
|
146
|
+
const atlasKey = atlasNameForFont(this.font)
|
|
147
|
+
const nextLayoutKey =
|
|
148
|
+
this.text +
|
|
149
|
+
'\u0001' +
|
|
150
|
+
String(this.fontSize) +
|
|
151
|
+
'\u0001' +
|
|
152
|
+
String(this.letterSpacing) +
|
|
153
|
+
'\u0001' +
|
|
154
|
+
atlasKey +
|
|
155
|
+
'\u0001' +
|
|
156
|
+
String(this.anchorX) +
|
|
157
|
+
'\u0001' +
|
|
158
|
+
String(this.anchorY)
|
|
159
|
+
if (nextLayoutKey === this.layoutKey) {
|
|
160
|
+
// Geometry inputs unchanged: only refresh paint state (color/opacity),
|
|
161
|
+
// matching what per-frame HUD pulses actually vary.
|
|
162
|
+
setMaterialHex(this.material, this.color)
|
|
163
|
+
this.material.opacity = this.fillOpacity
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const atlas = atlasForFont(this.font)
|
|
167
|
+
this.layoutKey = nextLayoutKey
|
|
168
|
+
const laidOut = layoutText(atlas, this.text, this.fontSize, this.letterSpacing)
|
|
169
|
+
// Anchor offsets: troika's anchorX left|center|right and anchorY
|
|
170
|
+
// top|middle|bottom (the subset the app uses). The layout origin is the
|
|
171
|
+
// left edge of the first baseline.
|
|
172
|
+
let offsetX = 0
|
|
173
|
+
if (this.anchorX === 'center') offsetX = -laidOut.width / 2
|
|
174
|
+
else if (this.anchorX === 'right') offsetX = -laidOut.width
|
|
175
|
+
let offsetY = 0
|
|
176
|
+
if (this.anchorY === 'middle') offsetY = -(laidOut.ascent + laidOut.descent) / 2
|
|
177
|
+
else if (this.anchorY === 'top') offsetY = -laidOut.ascent
|
|
178
|
+
else if (this.anchorY === 'bottom') offsetY = -laidOut.descent
|
|
179
|
+
// 'bottom-baseline' needs no offset: the layout origin IS the baseline.
|
|
180
|
+
const positions = laidOut.positions
|
|
181
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
182
|
+
positions[i] += offsetX
|
|
183
|
+
positions[i + 1] += offsetY
|
|
184
|
+
}
|
|
185
|
+
// Keep the BufferAttribute objects stable when the next layout has the
|
|
186
|
+
// same storage shape. WebGLAttributes keys its native buffer cache by
|
|
187
|
+
// attribute identity, so swapping the entire geometry would otherwise
|
|
188
|
+
// delete and recreate three GL buffers for every same-length score change.
|
|
189
|
+
// A size or typed-array-kind change still replaces and disposes the old
|
|
190
|
+
// geometry so WebGLAttributes can allocate correctly-sized storage.
|
|
191
|
+
const previous: BufferGeometry = this.mesh.geometry
|
|
192
|
+
const next = laidOut.geometry
|
|
193
|
+
const previousPosition = previous.getAttribute('position')
|
|
194
|
+
const nextPosition = next.getAttribute('position')
|
|
195
|
+
const previousUv = previous.getAttribute('uv')
|
|
196
|
+
const nextUv = next.getAttribute('uv')
|
|
197
|
+
const previousIndex = previous.index
|
|
198
|
+
const nextIndex = next.index
|
|
199
|
+
const matchingIndexStorage =
|
|
200
|
+
previousIndex !== null &&
|
|
201
|
+
nextIndex !== null &&
|
|
202
|
+
((previousIndex.array instanceof Uint16Array && nextIndex.array instanceof Uint16Array) ||
|
|
203
|
+
(previousIndex.array instanceof Uint32Array && nextIndex.array instanceof Uint32Array))
|
|
204
|
+
if (
|
|
205
|
+
previousPosition instanceof BufferAttribute &&
|
|
206
|
+
nextPosition instanceof BufferAttribute &&
|
|
207
|
+
previousUv instanceof BufferAttribute &&
|
|
208
|
+
nextUv instanceof BufferAttribute &&
|
|
209
|
+
previousPosition.array instanceof Float32Array &&
|
|
210
|
+
nextPosition.array instanceof Float32Array &&
|
|
211
|
+
previousUv.array instanceof Float32Array &&
|
|
212
|
+
nextUv.array instanceof Float32Array &&
|
|
213
|
+
matchingIndexStorage &&
|
|
214
|
+
previousPosition.array.length === nextPosition.array.length &&
|
|
215
|
+
previousUv.array.length === nextUv.array.length &&
|
|
216
|
+
previousIndex.array.length === nextIndex.array.length
|
|
217
|
+
) {
|
|
218
|
+
previousPosition.array = nextPosition.array
|
|
219
|
+
previousPosition.needsUpdate = true
|
|
220
|
+
previousUv.array = nextUv.array
|
|
221
|
+
previousUv.needsUpdate = true
|
|
222
|
+
previousIndex.array = nextIndex.array
|
|
223
|
+
previousIndex.needsUpdate = true
|
|
224
|
+
} else {
|
|
225
|
+
this.mesh.geometry = next
|
|
226
|
+
previous.dispose()
|
|
227
|
+
}
|
|
228
|
+
// Keep both sides of the identity comparison on the shared Texture
|
|
229
|
+
// carrier. Otherwise a derived DataTexture local can be incorrectly
|
|
230
|
+
// treated as disjoint from Material.map during native constant folding.
|
|
231
|
+
const nextMap: Texture = atlasTexture(atlas)
|
|
232
|
+
if (this.material.map !== nextMap) {
|
|
233
|
+
this.material.map = nextMap
|
|
234
|
+
// needsUpdate recompiles the shader program; only pay that when the
|
|
235
|
+
// map binding actually changed, not on every per-frame sync().
|
|
236
|
+
this.material.needsUpdate = true
|
|
237
|
+
}
|
|
238
|
+
setMaterialHex(this.material, this.color)
|
|
239
|
+
this.material.opacity = this.fillOpacity
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
dispose(): void {
|
|
243
|
+
if (this.mesh.geometry) this.mesh.geometry.dispose()
|
|
244
|
+
this.material.dispose()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export { TroikaText as Text }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Generic render-list instancing fixture. Reference meshes deliberately have
|
|
2
|
+
// observable callback overrides, so the renderer must retain ordinary draws.
|
|
3
|
+
import { PerspectiveCamera } from 'three/src/cameras/PerspectiveCamera.js'
|
|
4
|
+
import { BoxGeometry } from 'three/src/geometries/BoxGeometry.js'
|
|
5
|
+
import { Color } from 'three/src/math/Color.js'
|
|
6
|
+
import { MeshPhongMaterial } from 'three/src/materials/MeshPhongMaterial.js'
|
|
7
|
+
import { Mesh } from 'three/src/objects/Mesh.js'
|
|
8
|
+
import { Scene } from 'three/src/scenes/Scene.js'
|
|
9
|
+
import { AmbientLight } from 'three/src/lights/AmbientLight.js'
|
|
10
|
+
import { DirectionalLight } from 'three/src/lights/DirectionalLight.js'
|
|
11
|
+
import { WebGLRenderer } from 'three/src/renderers/WebGLRenderer.js'
|
|
12
|
+
import { createNativeWebGLCanvas } from '../src/nativeWebGL'
|
|
13
|
+
import { nativeWebGLSwap, syncNativeWebGLSize } from '../src/nativeWebGLHost'
|
|
14
|
+
|
|
15
|
+
export function startRealThreeFrameLoop(width: number, height: number, aspect: number): void {
|
|
16
|
+
const canvas = createNativeWebGLCanvas(width, height)
|
|
17
|
+
const context = canvas.getContext('webgl2')
|
|
18
|
+
if (!context) throw new Error('Native instancing validation requires a WebGL2 context')
|
|
19
|
+
const renderer = new WebGLRenderer({canvas, context, antialias:false, alpha:false, depth:true, stencil:true})
|
|
20
|
+
renderer.setPixelRatio(1)
|
|
21
|
+
renderer.setSize(width,height,false)
|
|
22
|
+
renderer.setClearColor(new Color(0x102030),1)
|
|
23
|
+
const camera = new PerspectiveCamera(55,aspect,.1,100)
|
|
24
|
+
camera.position.z = 7
|
|
25
|
+
const reference = new Scene()
|
|
26
|
+
const candidate = new Scene()
|
|
27
|
+
const geometry = new BoxGeometry(1.25,1.25,1.25)
|
|
28
|
+
const material = new MeshPhongMaterial({color:0xff6010,flatShading:false})
|
|
29
|
+
const instances: Mesh[] = []
|
|
30
|
+
let callbacks = 0
|
|
31
|
+
for (let i=0;i<2;i++) {
|
|
32
|
+
const original = new Mesh(geometry,material)
|
|
33
|
+
original.position.set(i===0?-1.25:1.25,0,0)
|
|
34
|
+
original.scale.set(1,i===0?.75:1.25,.5)
|
|
35
|
+
original.rotation.x = Math.PI / 2
|
|
36
|
+
original.onBeforeRender = () => { callbacks++ }
|
|
37
|
+
reference.add(original)
|
|
38
|
+
const mesh = new Mesh(geometry,material)
|
|
39
|
+
mesh.position.copy(original.position)
|
|
40
|
+
mesh.scale.copy(original.scale)
|
|
41
|
+
mesh.rotation.copy(original.rotation)
|
|
42
|
+
candidate.add(mesh)
|
|
43
|
+
instances.push(mesh)
|
|
44
|
+
}
|
|
45
|
+
reference.add(new AmbientLight(0xffffff, .5))
|
|
46
|
+
candidate.add(new AmbientLight(0xffffff, .5))
|
|
47
|
+
const light = new DirectionalLight(0xffffff,2)
|
|
48
|
+
light.position.set(3,5,7)
|
|
49
|
+
reference.add(light)
|
|
50
|
+
const light2 = new DirectionalLight(0xffffff,2)
|
|
51
|
+
light2.position.copy(light.position)
|
|
52
|
+
candidate.add(light2)
|
|
53
|
+
let frame=0
|
|
54
|
+
function renderFrame(): void {
|
|
55
|
+
frame++
|
|
56
|
+
camera.aspect=syncNativeWebGLSize(aspect)
|
|
57
|
+
camera.updateProjectionMatrix()
|
|
58
|
+
if(frame===241) instances[0].visible=false
|
|
59
|
+
renderer.render(frame<121?reference:candidate,camera)
|
|
60
|
+
if(frame===90||frame===180||frame===300) console.error('[auto.instance]',frame,renderer.info.render.calls,renderer.info.render.triangles,callbacks)
|
|
61
|
+
nativeWebGLSwap()
|
|
62
|
+
if(frame<301) requestAnimationFrame(renderFrame)
|
|
63
|
+
else console.error('[batch.render] complete')
|
|
64
|
+
}
|
|
65
|
+
requestAnimationFrame(renderFrame)
|
|
66
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Rendered by the existing Three ANGLE Metal app shell through the test-only
|
|
2
|
+
// wrapper plugin. The two scenes must produce identical framebuffer pixels.
|
|
3
|
+
import { PerspectiveCamera } from 'three/src/cameras/PerspectiveCamera.js'
|
|
4
|
+
import { BoxGeometry } from 'three/src/geometries/BoxGeometry.js'
|
|
5
|
+
import { Color } from 'three/src/math/Color.js'
|
|
6
|
+
import { Matrix4 } from 'three/src/math/Matrix4.js'
|
|
7
|
+
import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js'
|
|
8
|
+
import { Mesh } from 'three/src/objects/Mesh.js'
|
|
9
|
+
import { BatchedMesh } from 'three/src/objects/BatchedMesh.js'
|
|
10
|
+
import { Scene } from 'three/src/scenes/Scene.js'
|
|
11
|
+
import { WebGLRenderer } from 'three/src/renderers/WebGLRenderer.js'
|
|
12
|
+
import { createNativeWebGLCanvas } from '../src/nativeWebGL'
|
|
13
|
+
import { nativeWebGLSwap, syncNativeWebGLSize } from '../src/nativeWebGLHost'
|
|
14
|
+
|
|
15
|
+
export function startRealThreeFrameLoop(width: number, height: number, aspect: number): void {
|
|
16
|
+
const canvas = createNativeWebGLCanvas(width, height)
|
|
17
|
+
const context = canvas.getContext('webgl2')
|
|
18
|
+
if (!context) throw new Error('Native batching validation requires a WebGL2 context')
|
|
19
|
+
const renderer = new WebGLRenderer({ canvas, context, antialias: false, alpha: false, depth: true, stencil: true })
|
|
20
|
+
renderer.setPixelRatio(1)
|
|
21
|
+
renderer.setSize(width, height, false)
|
|
22
|
+
renderer.setClearColor(new Color(0x102030), 1)
|
|
23
|
+
const camera = new PerspectiveCamera(55, aspect, 0.1, 100)
|
|
24
|
+
camera.position.z = 7
|
|
25
|
+
const reference = new Scene()
|
|
26
|
+
const candidate = new Scene()
|
|
27
|
+
const geometry = new BoxGeometry(1.25, 1.25, 1.25)
|
|
28
|
+
const colors = [new Color(0xff4010), new Color(0x20c0ff)]
|
|
29
|
+
const batch = new BatchedMesh(4, 128, 256, new MeshBasicMaterial({ color: 0xffffff }))
|
|
30
|
+
const geometryId = batch.addGeometry(geometry)
|
|
31
|
+
for (let i = 0; i < 2; i++) {
|
|
32
|
+
const matrix = new Matrix4().makeTranslation(i === 0 ? -1 : 1, 0, 0)
|
|
33
|
+
const mesh = new Mesh(geometry, new MeshBasicMaterial({ color: colors[i] }))
|
|
34
|
+
mesh.applyMatrix4(matrix)
|
|
35
|
+
reference.add(mesh)
|
|
36
|
+
const instance = batch.addInstance(geometryId)
|
|
37
|
+
batch.setMatrixAt(instance, matrix)
|
|
38
|
+
batch.setColorAt(instance, colors[i])
|
|
39
|
+
}
|
|
40
|
+
candidate.add(batch)
|
|
41
|
+
let frame = 0
|
|
42
|
+
function renderFrame(): void {
|
|
43
|
+
frame++
|
|
44
|
+
camera.aspect = syncNativeWebGLSize(aspect)
|
|
45
|
+
camera.updateProjectionMatrix()
|
|
46
|
+
// The existing native host dumps frames 90, 180 and 300. The last frame
|
|
47
|
+
// verifies that hiding one instance removes its draw without stale pixels.
|
|
48
|
+
if (frame === 241) batch.setVisibleAt(0, false)
|
|
49
|
+
renderer.render(frame < 121 ? reference : candidate, camera)
|
|
50
|
+
if (frame === 90 || frame === 180 || frame === 300) {
|
|
51
|
+
console.error('[batch.render]', frame, renderer.info.render.calls, renderer.info.render.triangles, batch._multiDrawCount)
|
|
52
|
+
}
|
|
53
|
+
nativeWebGLSwap()
|
|
54
|
+
if (frame < 301) requestAnimationFrame(renderFrame)
|
|
55
|
+
else console.error('[batch.render] complete')
|
|
56
|
+
}
|
|
57
|
+
requestAnimationFrame(renderFrame)
|
|
58
|
+
}
|