@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.
@@ -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
+ }