lecodes-gl-lite 2.0.0
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/README.md +27 -0
- package/package.json +33 -0
- package/src/assets/decoders/README.md +13 -0
- package/src/assets/decoders/basis_transcoder.js +19 -0
- package/src/assets/decoders/basis_transcoder.wasm +0 -0
- package/src/assets/decoders/draco_decoder.wasm +0 -0
- package/src/assets/decoders/draco_wasm_wrapper.js +117 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/core/animBridge.ts +51 -0
- package/src/core/boneNames.ts +22 -0
- package/src/core/frame.ts +230 -0
- package/src/core/glbStructure.ts +100 -0
- package/src/core/headlessBridge.ts +92 -0
- package/src/core/mapView.ts +195 -0
- package/src/core/math3d.ts +275 -0
- package/src/core/particleBridge.ts +154 -0
- package/src/core/physicsBridge.ts +305 -0
- package/src/core/recording.ts +59 -0
- package/src/core/sceneCore.ts +833 -0
- package/src/core/serialize.ts +144 -0
- package/src/gen/unsupported.gen.ts +212 -0
- package/src/index.ts +5 -0
- package/src/vendor/creator-anim/dist/creatorAnim.d.ts +123 -0
- package/src/vendor/creator-anim/dist/creatorAnim.js +14 -0
- package/src/vendor/creator-anim/js/index.ts +656 -0
- package/src/vendor/creator-nav/dist/creatorNav.d.ts +56 -0
- package/src/vendor/creator-nav/dist/creatorNav.js +14 -0
- package/src/vendor/creator-nav/js/index.ts +400 -0
- package/src/vendor/creator-particles/dist/creatorParticles.d.ts +43 -0
- package/src/vendor/creator-particles/dist/creatorParticles.js +14 -0
- package/src/vendor/creator-particles/js/index.ts +137 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.d.ts +107 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.js +14 -0
- package/src/vendor/creator-physics/js/index.ts +515 -0
- package/src/vite-env.d.ts +1 -0
- package/src/webgl/batching.ts +453 -0
- package/src/webgl/createViewerLite.ts +1627 -0
- package/src/webgl/customMaterial.ts +228 -0
- package/src/webgl/decoders/basis.ts +123 -0
- package/src/webgl/decoders/draco.ts +110 -0
- package/src/webgl/decoders/index.ts +68 -0
- package/src/webgl/decoders/worker.ts +45 -0
- package/src/webgl/editor.ts +1297 -0
- package/src/webgl/glb.ts +593 -0
- package/src/webgl/ibl.ts +137 -0
- package/src/webgl/shaders.ts +779 -0
|
@@ -0,0 +1,1627 @@
|
|
|
1
|
+
// The lite web 3D engine: a from-scratch WebGL2 implementation of the `_creator` bridge
|
|
2
|
+
// (sdk/src/bridges.d.ts) built on OGL instead of the 3.2MB Filament wasm. Same object
|
|
3
|
+
// shape as viewer/createViewer.ts, so host.ts / the SDK / the touch system consume it unchanged.
|
|
4
|
+
//
|
|
5
|
+
// Scene semantics live in the shared, GL-free SceneCore (gl/core) — the SAME core the headless
|
|
6
|
+
// renderer drives — so the pixels a human sees and the JSON an AI reads come from one state and can't
|
|
7
|
+
// disagree. This file is the WebGL BACKEND for that core: it owns the OGL objects (Transforms,
|
|
8
|
+
// Programs, Geometry/Mesh, Textures) keyed by the core's entity ids and reacts to the core's
|
|
9
|
+
// SceneBackend hooks (build a Program on materialCreated, a Mesh on meshSet, sample a clip on
|
|
10
|
+
// morphWeights, …). The scene-graph bridge methods come from core.methods; this file adds the
|
|
11
|
+
// GL-only + capability methods (textures, shadows, raycast, AR throws, capture).
|
|
12
|
+
//
|
|
13
|
+
// Lighting follows Filament's model: photometric intensities (lux) × the default-camera exposure, the
|
|
14
|
+
// published BRDF, the same neutral IBL asset (KTX cubemap + SH). See shaders.ts. Custom .mat shaders
|
|
15
|
+
// run via the backend's webgl-lite JSON artifact (customMaterial.ts); a binary filamat or an
|
|
16
|
+
// `unsupported` artifact falls back to unlit white + warn.
|
|
17
|
+
//
|
|
18
|
+
// Not implemented (honest stubs, same failure shape as the Filament web build where possible):
|
|
19
|
+
// particles (warn-once no-op), AR/VR (throw / reject), Jolt physics (the core's creator-physics wasm),
|
|
20
|
+
// bloom (no-op). The headless backend records those instead (recording.ts).
|
|
21
|
+
|
|
22
|
+
import { Camera, Geometry, Mesh, GLTFSkin, Program, Renderer, RenderTarget, Texture, Transform, type OGLRenderingContext } from 'ogl'
|
|
23
|
+
import { _fetch, getBlob, getBuffer as getFetchedBuffer, getLocalName, hasLocalBuffer, putBuffer, putLocalBuffer } from 'lecodes-web-shared/src/utils/fetch'
|
|
24
|
+
import { getPlayer, removePlayers } from 'lecodes-web-shared/src/utils/mediaplayer'
|
|
25
|
+
import type { CanvasHost } from 'lecodes-web-shared/src/canvas/createCanvasHost'
|
|
26
|
+
import {
|
|
27
|
+
identity, intersectRayBox, intersectRaySphere, invertTRS, multiply, orthographic,
|
|
28
|
+
transformPoint, type Mat4Like,
|
|
29
|
+
} from '../core/math3d'
|
|
30
|
+
import {
|
|
31
|
+
EXPOSURE, depthFragment, depthVertex, gltfVertex, hexToLinearRgb, hexToLinearRgba,
|
|
32
|
+
litFragment, litVertex, shadowCatcherFragment, terrainFragment, tonemapJs, unlitBlendFragment, unlitFragment,
|
|
33
|
+
videoFragment, wireFragment,
|
|
34
|
+
} from './shaders'
|
|
35
|
+
import { loadIbl, type IblData } from './ibl'
|
|
36
|
+
import { buildEdgeIndices, createBlackCubemap, createWhiteTexture, loadGlbLite } from './glb'
|
|
37
|
+
import { buildCustomProgram, parseLiteMaterial } from './customMaterial'
|
|
38
|
+
import { createEditorLayer, type EditorHandlers, type EditorLayer } from './editor'
|
|
39
|
+
import { createBatcher, type Batcher } from './batching'
|
|
40
|
+
import { particlesPointVertex, particlesPointFragment, particlesQuadVertex, particlesQuadFragment } from './shaders'
|
|
41
|
+
import { createSceneCore, type SceneBackend, type UniformRec } from '../core/sceneCore'
|
|
42
|
+
import { loadCreatorAnim } from '../core/animBridge'
|
|
43
|
+
import { loadCreatorPhysics } from '../core/physicsBridge'
|
|
44
|
+
import { loadCreatorParticles, type ParticleFrame } from '../core/particleBridge'
|
|
45
|
+
import { MODE } from '../vendor/creator-particles/js/index'
|
|
46
|
+
import { serialize as serializeScene3d } from '../core/serialize'
|
|
47
|
+
import { unsupported } from '../gen/unsupported.gen'
|
|
48
|
+
// The global `Handle` the contract's creators return (declared with the tree bridge): without it
|
|
49
|
+
// gl.d.ts reads `Handle` as any and the `satisfies` check cannot see a creator returning a number.
|
|
50
|
+
import type {} from 'lecodes-sdk/bridges/tree'
|
|
51
|
+
import iblTextureUrl from '../assets/neutral_ibl128.ktx?url'
|
|
52
|
+
|
|
53
|
+
// `backend` is only ever a name fragment the compiled bundle substitutes into a shader URL
|
|
54
|
+
// (`<hash>_${_creator.backend}.filamat`); 'webgl-lite' selects the parsed-.mat JSON artifact
|
|
55
|
+
// (sdk/compile liteMaterial.ts) this engine wraps in its own GLSL template.
|
|
56
|
+
export const basePropsLite = {
|
|
57
|
+
backend: 'webgl-lite',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Same shape as the full viewer's ViewerOptions (viewer/createViewer.ts) — defined locally so the
|
|
61
|
+
// lite engine has no dependency on the viewer package.
|
|
62
|
+
export type ViewerOptions = {
|
|
63
|
+
preloads?: Set<string>
|
|
64
|
+
/** The core 2D-canvas host (`_creatorCanvas`); lets createTextureFromCanvas read baked surfaces. */
|
|
65
|
+
canvasHost?: CanvasHost
|
|
66
|
+
/** Clear to transparent instead of the skybox color (and open the context with an alpha
|
|
67
|
+
* channel). Capture hosts use this so PNG thumbnails composite over any page theme. */
|
|
68
|
+
transparent?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const FOV = 60 // vertical fov set by the native setViewport (scene.cpp) — matches the core default
|
|
72
|
+
const NEAR = 0.01
|
|
73
|
+
const FAR = 1000 // matches the native far plane (scene.cpp setViewport)
|
|
74
|
+
|
|
75
|
+
type Collider =
|
|
76
|
+
| { type: 'box', center: number[], extent: number[] }
|
|
77
|
+
| { type: 'sphere', center: number[], radius: number }
|
|
78
|
+
|
|
79
|
+
/** The OGL objects the backend keeps per core entity id. `transform.matrix` is the core entity's `m`. */
|
|
80
|
+
type GlEnt = { transform: Transform, drawables: Mesh[] }
|
|
81
|
+
|
|
82
|
+
type MaterialKind = 'lit' | 'unlit' | 'video' | 'shadow' | 'terrain' | 'particles' | 'particles-quad' | 'gltf' | 'custom' | 'unknown'
|
|
83
|
+
|
|
84
|
+
/** The OGL objects behind one particle system (creator-particles' stream → one dynamic drawable). */
|
|
85
|
+
type GlParticles = {
|
|
86
|
+
materialId: number
|
|
87
|
+
mesh: Mesh | null
|
|
88
|
+
geometry: Geometry | null
|
|
89
|
+
/** The interleaved stream buffer (points / quads / ribbon); mesh mode uses plain attributes. */
|
|
90
|
+
buffer: WebGLBuffer | null
|
|
91
|
+
version: number
|
|
92
|
+
mode: number
|
|
93
|
+
}
|
|
94
|
+
type GlMaterial = {
|
|
95
|
+
kind: MaterialKind
|
|
96
|
+
program: Program
|
|
97
|
+
/** custom (.mat) materials: declared parameter names — the SDK sets uniforms by the bare
|
|
98
|
+
* parameter name, the program declares them as materialParams_<name> */
|
|
99
|
+
customParams?: Set<string>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// glTF ubershader parameter names (what the SDK sets on model materials) → our uniform names.
|
|
103
|
+
const GLTF_UNIFORM_ALIAS: Record<string, string> = {
|
|
104
|
+
baseColorFactor: 'uBaseColorFactor',
|
|
105
|
+
metallicFactor: 'uMetallic',
|
|
106
|
+
roughnessFactor: 'uRoughness',
|
|
107
|
+
emissiveFactor: 'uEmissive',
|
|
108
|
+
baseColorMap: 'uBaseColorMap',
|
|
109
|
+
baseColorIndex: 'uBaseColorIndex',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const createViewerLite = async (canvas: HTMLCanvasElement, options: ViewerOptions = {}) => {
|
|
113
|
+
const renderer = new Renderer({
|
|
114
|
+
canvas,
|
|
115
|
+
dpr: 1, // set per-resize to the host's resolution factor (devicePixelRatio × CSS zoom); see _onResize
|
|
116
|
+
alpha: options.transparent ?? false,
|
|
117
|
+
antialias: true,
|
|
118
|
+
premultipliedAlpha: true,
|
|
119
|
+
powerPreference: 'high-performance',
|
|
120
|
+
preserveDrawingBuffer: options.preloads?.has('capture') ?? false,
|
|
121
|
+
})
|
|
122
|
+
const gl = renderer.gl as OGLRenderingContext & WebGL2RenderingContext
|
|
123
|
+
renderer.autoClear = false // we clear manually (per-scene clear color, overlay depth clears)
|
|
124
|
+
|
|
125
|
+
const white = createWhiteTexture(gl)
|
|
126
|
+
const blackCube = createBlackCubemap(gl)
|
|
127
|
+
|
|
128
|
+
// The SDK resolves built-in shaders via `_creatorFetch.local("lit.filamat")` — register the
|
|
129
|
+
// names with empty payloads so fetchLocal yields a systemId whose NAME identifies the material
|
|
130
|
+
// (the lite engine picks shaders by name; it never compiles filamat packages). ALWAYS registered,
|
|
131
|
+
// never from options.preloads: a host that passes no preloads (the scene editor) must still get
|
|
132
|
+
// lit materials — an unresolved name means every material silently renders unlit. Names the
|
|
133
|
+
// Filament engine already materialized keep their real buffers (same-page engine switches).
|
|
134
|
+
for (const name of [
|
|
135
|
+
'lit.filamat', 'unlit.filamat', 'unlit-transparent.filamat', 'video.filamat', 'shadow.filamat',
|
|
136
|
+
'particles.filamat', 'particles-quad.filamat', 'terrain.filamat',
|
|
137
|
+
]) {
|
|
138
|
+
if (!hasLocalBuffer(name)) putLocalBuffer(name, new ArrayBuffer(0))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- shared per-frame lighting uniforms (every lit/gltf/shadow program references these) ------
|
|
142
|
+
const sceneUniforms = {
|
|
143
|
+
sunDirection: { value: [0, -1, 0] as number[] },
|
|
144
|
+
sunColorIntensity: { value: [1, 1, 1, 0] as number[] },
|
|
145
|
+
iblLuminance: { value: 0 },
|
|
146
|
+
// NOTE: uniform ARRAYS must be plain JS arrays — OGL's name-component resolution drops typed
|
|
147
|
+
// arrays for `iblSH[0]`-style active uniforms.
|
|
148
|
+
iblSH: { value: new Array(27).fill(0) as number[] },
|
|
149
|
+
envMap: { value: blackCube as Texture },
|
|
150
|
+
envMapMaxLod: { value: 0 },
|
|
151
|
+
shadowMap: { value: white as Texture },
|
|
152
|
+
shadowEnabled: { value: 0 },
|
|
153
|
+
shadowMapSize: { value: 1024 },
|
|
154
|
+
shadowMatrix: { value: identity() },
|
|
155
|
+
exposure: { value: EXPOSURE },
|
|
156
|
+
}
|
|
157
|
+
// getUserTime() for custom .mat materials: [seconds, fract(seconds), 0, 0], updated per frame.
|
|
158
|
+
const userTime = { value: [0, 0, 0, 0] as number[] }
|
|
159
|
+
|
|
160
|
+
// The same neutral IBL the Filament build ships (KTX cubemap + SH metadata).
|
|
161
|
+
let ibl: IblData | null = null
|
|
162
|
+
const iblReady = fetch(iblTextureUrl)
|
|
163
|
+
.then((r) => r.arrayBuffer())
|
|
164
|
+
.then((buf) => {
|
|
165
|
+
ibl = loadIbl(gl, buf)
|
|
166
|
+
sceneUniforms.envMap.value = ibl.cubemap
|
|
167
|
+
sceneUniforms.envMapMaxLod.value = ibl.maxLod
|
|
168
|
+
sceneUniforms.iblSH.value = Array.from(ibl.sh)
|
|
169
|
+
})
|
|
170
|
+
.catch((e) => console.warn('[viewer-lite] IBL load failed:', e))
|
|
171
|
+
|
|
172
|
+
// ---- WebGL backend state (GL objects keyed by the core's ids) ---------------------------------
|
|
173
|
+
const glEnts = new Map<number, GlEnt>()
|
|
174
|
+
const sceneRoots = new Map<number, Transform>()
|
|
175
|
+
const sceneColliders = new Map<number, Map<number, Collider>>() // web CPU raycast (not in the core)
|
|
176
|
+
const glMaterials = new Map<number, GlMaterial>()
|
|
177
|
+
const glTextures = new Map<number, Texture>()
|
|
178
|
+
const glParticles = new Map<number, GlParticles>()
|
|
179
|
+
/** viewportHeightPx / (2 tan(fov/2)) — point sprites' world-space diameter (particles.mat's material global 3). */
|
|
180
|
+
const perspScale = { value: 1 }
|
|
181
|
+
/** CPU copies of raw-pixel textures (createTexturePixels) for sub-rectangle updates. */
|
|
182
|
+
const pixelTextures = new Map<number, { rgba: Uint8Array, width: number, height: number, channels: number }>()
|
|
183
|
+
let nextTexture = 1
|
|
184
|
+
let castShadowsGlobal = true
|
|
185
|
+
let receiveShadowsGlobal = true
|
|
186
|
+
/** Engine-side editor layer (grid/outline/gizmo/picking) — created on _editorConnect, editor-only. */
|
|
187
|
+
let editorLayer: EditorLayer | null = null
|
|
188
|
+
const activeVideoTextures: { video: HTMLVideoElement, texture: Texture, lastTime: number }[] = []
|
|
189
|
+
|
|
190
|
+
const glEnt = (id: number): GlEnt => {
|
|
191
|
+
let g = glEnts.get(id)
|
|
192
|
+
if (!g) {
|
|
193
|
+
const transform = new Transform()
|
|
194
|
+
transform.matrixAutoUpdate = false
|
|
195
|
+
;(transform as any)._entId = id
|
|
196
|
+
g = { transform, drawables: [] }
|
|
197
|
+
glEnts.set(id, g)
|
|
198
|
+
}
|
|
199
|
+
return g
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- materials --------------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
// `blend` splits the two unlit materials: unlit.filamat is opaque (depth-written, no blending),
|
|
205
|
+
// unlit-transparent.filamat is premultiplied alpha. Everything else ignores it.
|
|
206
|
+
const makeMaterial = (kind: MaterialKind, blend = false): GlMaterial => {
|
|
207
|
+
let program: Program
|
|
208
|
+
if (kind === 'lit') {
|
|
209
|
+
program = new Program(gl, {
|
|
210
|
+
vertex: litVertex,
|
|
211
|
+
fragment: litFragment,
|
|
212
|
+
uniforms: {
|
|
213
|
+
...sceneUniforms,
|
|
214
|
+
// factor×map identity defaults — color alone or map alone shows through unchanged
|
|
215
|
+
baseColor: { value: [1, 1, 1] },
|
|
216
|
+
baseColorMap: { value: white },
|
|
217
|
+
roughness: { value: 0 },
|
|
218
|
+
metallic: { value: 0 },
|
|
219
|
+
} as any,
|
|
220
|
+
})
|
|
221
|
+
} else if (kind === 'terrain') {
|
|
222
|
+
// terrain.mat's twin (shaders.ts terrainFragment): the SDK primes every scalar; samplers start white
|
|
223
|
+
program = new Program(gl, {
|
|
224
|
+
vertex: litVertex,
|
|
225
|
+
fragment: terrainFragment,
|
|
226
|
+
uniforms: {
|
|
227
|
+
...sceneUniforms,
|
|
228
|
+
control: { value: white }, albedo0: { value: white }, albedo1: { value: white }, albedo2: { value: white }, albedo3: { value: white },
|
|
229
|
+
normals01: { value: white }, normals23: { value: white },
|
|
230
|
+
tiling: { value: [8, 8, 8, 8] }, roughness: { value: [1, 1, 1, 1] }, normalScale: { value: [0, 0, 0, 0] }, triplanar: { value: [0, 0, 0, 0] },
|
|
231
|
+
gridSize: { value: [2, 2] }, cellSize: { value: 1 }, tint: { value: [1, 1, 1] },
|
|
232
|
+
} as any,
|
|
233
|
+
})
|
|
234
|
+
} else if (kind === 'video') {
|
|
235
|
+
program = new Program(gl, {
|
|
236
|
+
vertex: litVertex,
|
|
237
|
+
fragment: videoFragment,
|
|
238
|
+
// shadowMatrix: the shared vertex shader declares it even though video ignores shadows.
|
|
239
|
+
uniforms: { baseColorMap: { value: white }, shadowMatrix: sceneUniforms.shadowMatrix } as any,
|
|
240
|
+
})
|
|
241
|
+
} else if (kind === 'shadow') {
|
|
242
|
+
program = new Program(gl, {
|
|
243
|
+
vertex: litVertex,
|
|
244
|
+
fragment: shadowCatcherFragment,
|
|
245
|
+
uniforms: { ...sceneUniforms, color: { value: [0, 0, 0, 0.66] } } as any,
|
|
246
|
+
transparent: true,
|
|
247
|
+
})
|
|
248
|
+
program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
|
|
249
|
+
} else if (kind === 'particles' || kind === 'particles-quad') {
|
|
250
|
+
// particles.mat / particles-quad.mat twins (shaders.ts): the SDK's Material.particles primes
|
|
251
|
+
// every look knob; the stream carries the per-vertex rest. Premultiplied, no depth write.
|
|
252
|
+
const quad = kind === 'particles-quad'
|
|
253
|
+
program = new Program(gl, {
|
|
254
|
+
vertex: quad ? particlesQuadVertex : particlesPointVertex,
|
|
255
|
+
fragment: quad ? particlesQuadFragment : particlesPointFragment,
|
|
256
|
+
uniforms: {
|
|
257
|
+
baseColorMap: { value: white }, sheetSize: { value: [1, 1] },
|
|
258
|
+
emissive: { value: 0 }, additive: { value: 0 }, softness: { value: 0 }, depthFade: { value: 0 },
|
|
259
|
+
...(quad
|
|
260
|
+
? { mode: { value: 0 }, stretch: { value: 0 }, emissiveMap: { value: white }, emissiveColor: { value: [0, 0, 0] }, frameBlend: { value: 0 } }
|
|
261
|
+
: { perspScale }),
|
|
262
|
+
} as any,
|
|
263
|
+
transparent: true,
|
|
264
|
+
depthWrite: false,
|
|
265
|
+
cullFace: false,
|
|
266
|
+
} as any)
|
|
267
|
+
program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
|
|
268
|
+
} else {
|
|
269
|
+
// unlit + unknown custom shaders (Material.load of a .filamat can't run here)
|
|
270
|
+
program = new Program(gl, {
|
|
271
|
+
vertex: litVertex,
|
|
272
|
+
fragment: blend ? unlitBlendFragment : unlitFragment,
|
|
273
|
+
uniforms: {
|
|
274
|
+
baseColor: { value: [1, 1, 1, 1] },
|
|
275
|
+
baseColorMap: { value: white },
|
|
276
|
+
shadowMatrix: sceneUniforms.shadowMatrix,
|
|
277
|
+
} as any,
|
|
278
|
+
transparent: blend,
|
|
279
|
+
})
|
|
280
|
+
if (blend) program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
|
|
281
|
+
}
|
|
282
|
+
return { kind, program }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Custom .mat material: the fetched buffer is the backend's webgl-lite JSON artifact (parsed
|
|
286
|
+
// .mat) — wrap its fragment body in the lite GLSL template. Null → caller falls back to unlit.
|
|
287
|
+
const makeCustomMaterial = (systemId: number): GlMaterial | null => {
|
|
288
|
+
const artifact = parseLiteMaterial(getFetchedBuffer(systemId))
|
|
289
|
+
if (!artifact) return null
|
|
290
|
+
if (artifact.unsupported) {
|
|
291
|
+
console.warn(`[viewer-lite] custom material not supported in lite (${artifact.unsupported}) — rendering unlit`)
|
|
292
|
+
return null
|
|
293
|
+
}
|
|
294
|
+
if (artifact.hasVertexBlock) {
|
|
295
|
+
console.warn('[viewer-lite] custom material has a vertex{} block — the custom vertex stage is ignored in lite')
|
|
296
|
+
}
|
|
297
|
+
const spec = buildCustomProgram(artifact)
|
|
298
|
+
if (!spec) return null
|
|
299
|
+
|
|
300
|
+
const uniforms: Record<string, { value: any }> = spec.needsSceneUniforms
|
|
301
|
+
? { ...sceneUniforms, userTime }
|
|
302
|
+
: { shadowMatrix: sceneUniforms.shadowMatrix, userTime }
|
|
303
|
+
for (const u of spec.uniforms) uniforms[u.key] = { value: u.value === 'texture' ? white : u.value }
|
|
304
|
+
|
|
305
|
+
const opaque = artifact.blending === 'opaque' || artifact.blending === 'masked'
|
|
306
|
+
const program = new Program(gl, {
|
|
307
|
+
vertex: litVertex,
|
|
308
|
+
fragment: spec.fragment,
|
|
309
|
+
uniforms: uniforms as any,
|
|
310
|
+
transparent: !opaque,
|
|
311
|
+
cullFace: artifact.doubleSided || artifact.culling === 'none' ? false
|
|
312
|
+
: artifact.culling === 'front' ? gl.FRONT : gl.BACK,
|
|
313
|
+
depthWrite: artifact.depthWrite,
|
|
314
|
+
} as any)
|
|
315
|
+
if (!gl.getProgramParameter(program.program, gl.LINK_STATUS)) {
|
|
316
|
+
console.warn('[viewer-lite] custom material failed to compile — rendering unlit')
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
if (!opaque) program.setBlendFunc(gl.ONE, artifact.blending === 'add' ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA)
|
|
320
|
+
return { kind: 'custom', program, customParams: spec.paramNames }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Build a GlMaterial from a shader NAME (createMaterial / builtinMaterial via the materialCreated hook).
|
|
324
|
+
// systemId is present for fetched shaders (needed to load a custom .mat), absent for a built-in one.
|
|
325
|
+
const buildProgramFor = (name: string, systemId?: number): GlMaterial => {
|
|
326
|
+
const kind: MaterialKind =
|
|
327
|
+
name.startsWith('lit') ? 'lit'
|
|
328
|
+
: name.startsWith('unlit') ? 'unlit'
|
|
329
|
+
: name.startsWith('video') ? 'video'
|
|
330
|
+
: name.startsWith('shadow') ? 'shadow'
|
|
331
|
+
: name.startsWith('terrain') ? 'terrain'
|
|
332
|
+
: name.startsWith('particles-quad') ? 'particles-quad'
|
|
333
|
+
: name.startsWith('particles') ? 'particles'
|
|
334
|
+
: 'unknown'
|
|
335
|
+
if (kind === 'unknown' && systemId !== undefined) {
|
|
336
|
+
const custom = makeCustomMaterial(systemId)
|
|
337
|
+
if (custom) return custom
|
|
338
|
+
console.warn(`[viewer-lite] custom shader can't run here (material "${name || systemId}") — rendering unlit`)
|
|
339
|
+
}
|
|
340
|
+
// 'unlit-transparent' is the same shader family (kind 'unlit'), drawn blended instead of opaque.
|
|
341
|
+
return makeMaterial(kind, name.startsWith('unlit-transparent'))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const materialUniform = (rec: GlMaterial, name: string): { value: any } => {
|
|
345
|
+
const key = rec.kind === 'gltf' ? (GLTF_UNIFORM_ALIAS[name] ?? name)
|
|
346
|
+
: rec.customParams?.has(name) ? `materialParams_${name}`
|
|
347
|
+
: name
|
|
348
|
+
const uniforms = rec.program.uniforms as Record<string, { value: any }>
|
|
349
|
+
if (!uniforms[key]) uniforms[key] = { value: 0 }
|
|
350
|
+
return uniforms[key]
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// React to a core uniform change (semantic value) by converting + setting it on the Program. Texture
|
|
354
|
+
// uniforms are finalized by setUniformTexture (which also carries wrap modes); here we bind by id.
|
|
355
|
+
const applyUniform = (materialId: number, uniform: string, u: UniformRec): void => {
|
|
356
|
+
const rec = glMaterials.get(materialId)
|
|
357
|
+
if (!rec) return
|
|
358
|
+
// Legacy bundles cleared the lit/unlit map with `useBaseColorMap = false` — the factor×map
|
|
359
|
+
// shaders dropped that flag, so translate the clear into the white-texture reset it meant
|
|
360
|
+
// (custom materials may declare their own useBaseColorMap; leave those alone).
|
|
361
|
+
if (uniform === 'useBaseColorMap' && (rec.kind === 'lit' || rec.kind === 'unlit')) {
|
|
362
|
+
if (u.kind === 'bool' && !u.v) {
|
|
363
|
+
const map = materialUniform(rec, 'baseColorMap')
|
|
364
|
+
if (map.value instanceof Texture) map.value = white
|
|
365
|
+
}
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
const target = materialUniform(rec, uniform)
|
|
369
|
+
switch (u.kind) {
|
|
370
|
+
case 'rgb': {
|
|
371
|
+
const [r, g, b] = hexToLinearRgb(u.v)
|
|
372
|
+
if (Array.isArray(target.value) && target.value.length === 4) target.value = [r, g, b, target.value[3]]
|
|
373
|
+
else target.value = [r, g, b]
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
case 'rgba': {
|
|
377
|
+
// A vec3 uniform (lit baseColor, terrain tint) takes the colour and drops the alpha, like
|
|
378
|
+
// creator-gl's FLOAT3 branch: every SDK colour is '#rrggbbaa' now, opaque or not.
|
|
379
|
+
const rgba = hexToLinearRgba(u.v)
|
|
380
|
+
target.value = Array.isArray(target.value) && target.value.length === 3 ? rgba.slice(0, 3) : rgba
|
|
381
|
+
break
|
|
382
|
+
}
|
|
383
|
+
case 'float': target.value = u.v; break
|
|
384
|
+
case 'bool': target.value = u.v ? 1 : 0; break
|
|
385
|
+
case 'array': target.value = Float32Array.from(u.v); break
|
|
386
|
+
case 'texture': { const tex = glTextures.get(u.v); if (tex) target.value = tex; break }
|
|
387
|
+
case 'null': if (target.value instanceof Texture) target.value = white; break
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// receiveShadows is per-drawable, programs are per-material — resolve at draw time.
|
|
392
|
+
const wireReceiveShadows = (mesh: Mesh, rec: GlMaterial): void => {
|
|
393
|
+
if (rec.kind !== 'lit' && rec.kind !== 'gltf' && rec.kind !== 'shadow' && rec.kind !== 'custom') return
|
|
394
|
+
mesh.onBeforeRender(() => {
|
|
395
|
+
const uniforms = mesh.program.uniforms as any
|
|
396
|
+
if (uniforms.shadowEnabled) {
|
|
397
|
+
const on = sceneUniforms.shadowEnabled.value === 1 && (mesh as any)._receiveShadows !== false
|
|
398
|
+
uniforms.shadowEnabled = on ? sceneUniforms.shadowEnabled : { value: 0 }
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Build the OGL Mesh for a primitive setMesh (core already recorded bounds + kind). The core always
|
|
404
|
+
// records the mesh; if the material never resolved to a program we skip the drawable (native/web).
|
|
405
|
+
const buildMesh = (entityId: number, materialId: number, arrays: {
|
|
406
|
+
vertices: Float32Array, normals: Float32Array, indices: Uint16Array, uv: Float32Array, colors?: Uint8Array,
|
|
407
|
+
}, meshType: number): void => {
|
|
408
|
+
const rec = glMaterials.get(materialId)
|
|
409
|
+
if (!rec) return
|
|
410
|
+
const { vertices, normals, indices, uv, colors } = arrays
|
|
411
|
+
const geometry = new Geometry(gl, {
|
|
412
|
+
position: { size: 3, data: Float32Array.from(vertices) },
|
|
413
|
+
normal: { size: 3, data: normals.length ? Float32Array.from(normals) : new Float32Array(vertices.length) },
|
|
414
|
+
uv: { size: 2, data: uv.length ? Float32Array.from(uv) : new Float32Array((vertices.length / 3) * 2) },
|
|
415
|
+
// the vertex colours as 0..1 floats (Geometry.colors is bytes); a program that declares no
|
|
416
|
+
// `color` attribute simply ignores them — lite's custom materials have no getColor() yet
|
|
417
|
+
...(colors && colors.length ? { color: { size: 4, data: Float32Array.from(colors, (v) => v / 255) } } : {}),
|
|
418
|
+
index: { data: Uint16Array.from(indices) },
|
|
419
|
+
} as any)
|
|
420
|
+
geometry.computeBoundingBox()
|
|
421
|
+
const mode = meshType === 1 ? gl.LINES : meshType === 2 ? gl.POINTS : gl.TRIANGLES
|
|
422
|
+
const mesh = new Mesh(gl, { geometry, program: rec.program, mode })
|
|
423
|
+
mesh.matrixAutoUpdate = false
|
|
424
|
+
;(mesh as any)._entId = entityId
|
|
425
|
+
;(mesh as any)._castShadows = castShadowsGlobal
|
|
426
|
+
;(mesh as any)._receiveShadows = receiveShadowsGlobal
|
|
427
|
+
wireReceiveShadows(mesh, rec)
|
|
428
|
+
const g = glEnt(entityId)
|
|
429
|
+
for (const d of g.drawables) {
|
|
430
|
+
g.transform.removeChild(d) // native rebuilds the renderable
|
|
431
|
+
const wire = wireMeshes.get(d) as any
|
|
432
|
+
if (wire) { wire.setParent(null as any); wireMeshes.delete(d) }
|
|
433
|
+
}
|
|
434
|
+
g.drawables = [mesh]
|
|
435
|
+
g.transform.addChild(mesh)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Stage 2 of GLB load (the core built the structure entities): run OGL's GLTFLoader, then adopt the
|
|
439
|
+
// loader's node Transforms AS this GLB's entities (rebindMatrix) so animation/skinning — which the
|
|
440
|
+
// loader binds to those transforms — keep working, and attach the drawable meshes under them.
|
|
441
|
+
const attachGlb = async (systemId: number, rootId: number, entIds: number[]): Promise<void> => {
|
|
442
|
+
const buf = getFetchedBuffer(systemId)
|
|
443
|
+
let result
|
|
444
|
+
try {
|
|
445
|
+
result = await loadGlbLite(gl, buf!, sceneUniforms, white)
|
|
446
|
+
} catch (e) {
|
|
447
|
+
// Honest degrade: the core's structure entities (hierarchy, bounds, animation clocks) stay —
|
|
448
|
+
// this model just gets no drawables. A bad/unsupported GLB must not take the whole run down.
|
|
449
|
+
console.warn(`[viewer-lite] GLB "${getLocalName(systemId) ?? systemId}" drawables failed to load:`,
|
|
450
|
+
e instanceof Error ? e.message : e)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
result.nodes.forEach((node: any, i: number) => {
|
|
455
|
+
const coreId = entIds[i]
|
|
456
|
+
if (coreId === undefined) return
|
|
457
|
+
const g = glEnt(coreId)
|
|
458
|
+
g.transform.setParent(null as any) // drop the gl-free placeholder transform
|
|
459
|
+
core.rebindMatrix(coreId, node.matrix as unknown as Mat4Like)
|
|
460
|
+
node.matrixAutoUpdate = false
|
|
461
|
+
node._entId = coreId
|
|
462
|
+
g.transform = node
|
|
463
|
+
g.drawables = []
|
|
464
|
+
for (const child of node.children as any[]) {
|
|
465
|
+
if (child.draw) {
|
|
466
|
+
child.matrixAutoUpdate = false
|
|
467
|
+
child._entId = coreId
|
|
468
|
+
child._castShadows = castShadowsGlobal
|
|
469
|
+
child._receiveShadows = receiveShadowsGlobal
|
|
470
|
+
g.drawables.push(child)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
// The loader parents child nodes under parent nodes already; parent its scene roots under the
|
|
475
|
+
// model-root entity's transform so the whole model moves with the root.
|
|
476
|
+
for (const lr of result.roots as any[]) lr.setParent(glEnt(rootId).transform)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Skins cloned during the current `cloneEntity`, still pointing at the SOURCE's joints — the
|
|
480
|
+
* clones of those joints do not exist yet when their mesh is cloned (pre-order). `subtreeCloned`
|
|
481
|
+
* re-points them once the whole subtree is there. */
|
|
482
|
+
const pendingSkinClones: GLTFSkin[] = []
|
|
483
|
+
const cloneDrawables = (sourceId: number, cloneId: number): void => {
|
|
484
|
+
const src = glEnt(sourceId)
|
|
485
|
+
const copy = glEnt(cloneId)
|
|
486
|
+
for (const d of src.drawables as any[]) {
|
|
487
|
+
const mesh = d instanceof GLTFSkin
|
|
488
|
+
? new GLTFSkin(gl, { skeleton: d.skeleton, geometry: d.geometry, program: d.program, mode: d.mode })
|
|
489
|
+
: new Mesh(gl, { geometry: d.geometry, program: d.program, mode: d.mode })
|
|
490
|
+
if (mesh instanceof GLTFSkin) pendingSkinClones.push(mesh)
|
|
491
|
+
;(mesh as any)._entId = cloneId
|
|
492
|
+
;(mesh as any)._castShadows = d._castShadows
|
|
493
|
+
;(mesh as any)._receiveShadows = d._receiveShadows
|
|
494
|
+
mesh.frustumCulled = d.frustumCulled
|
|
495
|
+
mesh.matrixAutoUpdate = false
|
|
496
|
+
copy.drawables.push(mesh)
|
|
497
|
+
copy.transform.addChild(mesh)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** A clone's skin must follow the CLONE's joints: the Animator on the clone writes those, while
|
|
502
|
+
* the source's (a `defineScene` model template, never in a scene) stay at rest — sharing the
|
|
503
|
+
* source skeleton drew every cloned character in its bind pose. Each joint is swapped for its
|
|
504
|
+
* clone's Transform with the same inverse bind matrix; a joint outside the cloned subtree keeps
|
|
505
|
+
* the source's. Native gets this from gltfio's createInstance (one skeleton per instance). */
|
|
506
|
+
const rebindClonedSkins = (map: ReadonlyMap<number, number>): void => {
|
|
507
|
+
for (const mesh of pendingSkinClones.splice(0) as any[]) {
|
|
508
|
+
const joints = (mesh.skeleton.joints as any[]).map((j) => {
|
|
509
|
+
const cloneId = j._entId === undefined ? undefined : map.get(j._entId)
|
|
510
|
+
if (cloneId === undefined) return j
|
|
511
|
+
const t = glEnt(cloneId).transform as any
|
|
512
|
+
t.bindInverse = j.bindInverse
|
|
513
|
+
return t
|
|
514
|
+
})
|
|
515
|
+
mesh.skeleton = { ...mesh.skeleton, joints }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---- wireframe (editor viewport display modes — HOST API, not part of the bridge ABI) ---------
|
|
520
|
+
// A gl.LINES companion mesh per triangle drawable: unique-edge indices (buildEdgeIndices) over the
|
|
521
|
+
// SAME vertex data (shared GPU buffers when already uploaded), drawn with a solid-color program.
|
|
522
|
+
// 'overlay' keeps the shaded pass and polygon-offsets its fill so the lines win the depth tie;
|
|
523
|
+
// 'only' hides the shaded drawables. Existing LINES/POINTS content (grid, path lines) stays as-is.
|
|
524
|
+
// Companions are created lazily and re-synced every frame while a wire mode is active, so
|
|
525
|
+
// drawables that appear later (live patches, async GLB loads, clones) pick theirs up.
|
|
526
|
+
type WireframeMode = 'off' | 'overlay' | 'only'
|
|
527
|
+
let wireframeMode: WireframeMode = 'off'
|
|
528
|
+
|
|
529
|
+
const wireColor = { value: [0.05, 0.06, 0.08] as number[] }
|
|
530
|
+
const wireProgram = new Program(gl, {
|
|
531
|
+
vertex: litVertex,
|
|
532
|
+
fragment: wireFragment,
|
|
533
|
+
uniforms: { uWireColor: wireColor, shadowMatrix: sceneUniforms.shadowMatrix } as any,
|
|
534
|
+
})
|
|
535
|
+
const wireSkinProgram = new Program(gl, {
|
|
536
|
+
vertex: gltfVertex('HAS_SKIN'),
|
|
537
|
+
fragment: wireFragment,
|
|
538
|
+
uniforms: {
|
|
539
|
+
uWireColor: wireColor,
|
|
540
|
+
shadowMatrix: sceneUniforms.shadowMatrix,
|
|
541
|
+
boneTexture: { value: white },
|
|
542
|
+
boneTextureSize: { value: 4 },
|
|
543
|
+
} as any,
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
const wireGeometries = new WeakMap<object, Geometry>() // triangle geometry → LINES geometry
|
|
547
|
+
const wireMeshes = new WeakMap<object, Mesh>() // drawable → its wire companion
|
|
548
|
+
|
|
549
|
+
const wireGeometryFor = (src: any): Geometry => {
|
|
550
|
+
let g = wireGeometries.get(src)
|
|
551
|
+
if (!g) {
|
|
552
|
+
const attrs: Record<string, any> = {}
|
|
553
|
+
for (const [ name, a ] of Object.entries<any>(src.attributes)) {
|
|
554
|
+
if (name === 'index') continue
|
|
555
|
+
// Share the source attribute's CPU data AND (when already uploaded) its GPU buffer;
|
|
556
|
+
// stride/offset carry over for interleaved glTF attributes.
|
|
557
|
+
attrs[name] = {
|
|
558
|
+
size: a.size, data: a.data, type: a.type, normalized: a.normalized,
|
|
559
|
+
stride: a.stride, offset: a.offset, buffer: a.buffer, count: a.count,
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const position = src.attributes.position
|
|
563
|
+
const vertexCount = position ? (position.count ?? position.data.length / position.size) : 0
|
|
564
|
+
attrs.index = { data: buildEdgeIndices(src.attributes.index?.data, vertexCount) }
|
|
565
|
+
g = new Geometry(gl, attrs)
|
|
566
|
+
wireGeometries.set(src, g)
|
|
567
|
+
}
|
|
568
|
+
return g
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const ensureWireMesh = (d: any): Mesh | null => {
|
|
572
|
+
let wire = wireMeshes.get(d) as any
|
|
573
|
+
if (wire) return wire
|
|
574
|
+
if (!d.parent || !d.geometry?.attributes?.position) return null
|
|
575
|
+
const geometry = wireGeometryFor(d.geometry)
|
|
576
|
+
wire = d instanceof GLTFSkin
|
|
577
|
+
? new GLTFSkin(gl, { skeleton: d.skeleton, geometry, program: wireSkinProgram, mode: gl.LINES })
|
|
578
|
+
: new Mesh(gl, { geometry, program: wireProgram, mode: gl.LINES })
|
|
579
|
+
wire.frustumCulled = false
|
|
580
|
+
wire.matrixAutoUpdate = false
|
|
581
|
+
wire.matrix.copy(d.matrix)
|
|
582
|
+
wire._entId = d._entId
|
|
583
|
+
wire._castShadows = false
|
|
584
|
+
wire._receiveShadows = false
|
|
585
|
+
wire.setParent(d.parent)
|
|
586
|
+
wireMeshes.set(d, wire)
|
|
587
|
+
touchGraph()
|
|
588
|
+
return wire
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const updateWireframe = (): void => {
|
|
592
|
+
for (const g of glEnts.values()) {
|
|
593
|
+
for (const d of g.drawables as any[]) {
|
|
594
|
+
if (d.mode !== gl.TRIANGLES) continue
|
|
595
|
+
if (wireframeMode === 'off') {
|
|
596
|
+
d.visible = true
|
|
597
|
+
const wire = wireMeshes.get(d) as any
|
|
598
|
+
if (wire) wire.visible = false
|
|
599
|
+
} else {
|
|
600
|
+
const wire = ensureWireMesh(d) as any
|
|
601
|
+
d.visible = wireframeMode === 'overlay'
|
|
602
|
+
if (wire) wire.visible = true
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const updateVideoTextures = (): void => {
|
|
609
|
+
for (const v of activeVideoTextures) {
|
|
610
|
+
if (v.video.videoWidth === 0 || v.video.currentTime === v.lastTime) continue
|
|
611
|
+
v.texture.image = v.video as any
|
|
612
|
+
;(v.texture as any).needsUpdate = true
|
|
613
|
+
v.lastTime = v.video.currentTime
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---- render loop ------------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
const camera = new Camera(gl, { fov: FOV, near: NEAR, far: FAR, aspect: 1 })
|
|
620
|
+
camera.matrixAutoUpdate = false
|
|
621
|
+
|
|
622
|
+
let sceneOpened = false
|
|
623
|
+
let lastTime = 0
|
|
624
|
+
let lastAspect = 0
|
|
625
|
+
// last projection pushed to the OGL camera — the scene's own (setCameraProjection) or the defaults
|
|
626
|
+
let lastFov = FOV, lastNear = NEAR, lastFar = FAR
|
|
627
|
+
// Resolution factor (devicePixelRatio × CSS zoom) the backing store renders at, set by _onResize.
|
|
628
|
+
// The bridge's screen-space contract is LOGICAL px, so getViewRay scales its inputs up to backing
|
|
629
|
+
// px by this factor and getDisplaySize reports the logical size. See docs/simulator-plan.md phase 3.
|
|
630
|
+
let resFactor = 1
|
|
631
|
+
|
|
632
|
+
// Shadow pass resources (lazy)
|
|
633
|
+
let shadowTarget: RenderTarget | null = null
|
|
634
|
+
let shadowMapSize = 0
|
|
635
|
+
const depthProgram = new Program(gl, {
|
|
636
|
+
vertex: depthVertex(false),
|
|
637
|
+
fragment: depthFragment,
|
|
638
|
+
uniforms: { modelMatrix: { value: identity() }, shadowViewProjection: { value: identity() } } as any,
|
|
639
|
+
cullFace: gl.FRONT, // reduces acne on closed meshes
|
|
640
|
+
})
|
|
641
|
+
const depthSkinProgram = new Program(gl, {
|
|
642
|
+
vertex: depthVertex(true),
|
|
643
|
+
fragment: depthFragment,
|
|
644
|
+
uniforms: {
|
|
645
|
+
modelMatrix: { value: identity() },
|
|
646
|
+
shadowViewProjection: { value: identity() },
|
|
647
|
+
boneTexture: { value: white },
|
|
648
|
+
boneTextureSize: { value: 4 },
|
|
649
|
+
} as any,
|
|
650
|
+
cullFace: gl.FRONT,
|
|
651
|
+
})
|
|
652
|
+
// the batches' depth program: the world matrix is the instance stream (batching.ts)
|
|
653
|
+
const depthInstancedProgram = new Program(gl, {
|
|
654
|
+
vertex: depthVertex(false, true),
|
|
655
|
+
fragment: depthFragment,
|
|
656
|
+
uniforms: { shadowViewProjection: { value: identity() } } as any,
|
|
657
|
+
cullFace: gl.FRONT,
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// One batcher per scene root (the main scene, each overlay): its batches persist across frames,
|
|
661
|
+
// `collect` refreshes them from the graph, the passes draw from them. See batching.ts.
|
|
662
|
+
const batchers = new Map<Transform, Batcher>()
|
|
663
|
+
let batchingEnabled = true
|
|
664
|
+
const batcherFor = (root: Transform): Batcher => {
|
|
665
|
+
let b = batchers.get(root)
|
|
666
|
+
if (!b) { b = createBatcher(gl, renderer); b.setEnabled(batchingEnabled); batchers.set(root, b) }
|
|
667
|
+
return b
|
|
668
|
+
}
|
|
669
|
+
// The scene-graph mutation counter the batchers key their graph walk on. Every path that moves
|
|
670
|
+
// a node, re-parents it, shows/hides it or changes what it draws bumps it — the core's backend
|
|
671
|
+
// hooks cover the SDK, physics and animation; the host bumps for its own drawable changes
|
|
672
|
+
// (GLB attach, clones, particles, wireframe companions, flags). A frame whose counter didn't
|
|
673
|
+
// move skips the 57 k-node traversal entirely.
|
|
674
|
+
let graphVersion = 0
|
|
675
|
+
const touchGraph = (): void => { graphVersion++ }
|
|
676
|
+
/** OGL's world-matrix pass is a full recursive walk too — run it per root only when the graph
|
|
677
|
+
* moved since that root's last pass (the dirty flags it propagates were all set through
|
|
678
|
+
* `touchGraph`'s callers). */
|
|
679
|
+
const worldPassVersion = new Map<Transform, number>()
|
|
680
|
+
const updateWorld = (root: Transform): void => {
|
|
681
|
+
if (worldPassVersion.get(root) === graphVersion) return
|
|
682
|
+
root.updateMatrixWorld()
|
|
683
|
+
worldPassVersion.set(root, graphVersion)
|
|
684
|
+
}
|
|
685
|
+
/** Draw a scene root through the batcher with an arbitrary camera (the editor's preview inset
|
|
686
|
+
* reuses it — the main frame's shadow map / lighting uniforms stay as set). `force` re-walks
|
|
687
|
+
* the graph: the preview hides drawables behind the counter's back. */
|
|
688
|
+
const renderScene = (root: Transform, cam: Camera, force = false): void => {
|
|
689
|
+
const b = batcherFor(root)
|
|
690
|
+
updateWorld(root)
|
|
691
|
+
b.collect(root, cam, true, graphVersion, force)
|
|
692
|
+
b.drawMain(cam, sceneUniforms.shadowEnabled)
|
|
693
|
+
if (force) touchGraph() // the next main frame re-walks too (the hidden drawables come back)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The visible sun light entity that resolves to this scene, if any. */
|
|
697
|
+
const findSun = (sceneId: number) => {
|
|
698
|
+
for (const [id, e] of core.entities) {
|
|
699
|
+
if (e.light && core.methods.isVisible(id) && core.sceneOf(id)?.id === sceneId) return e.light
|
|
700
|
+
}
|
|
701
|
+
return null
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const collectMeshes = (root: Transform): Mesh[] => {
|
|
705
|
+
const out: Mesh[] = []
|
|
706
|
+
root.traverse((node: any) => {
|
|
707
|
+
if (!node.visible) return true
|
|
708
|
+
if (node.draw) out.push(node)
|
|
709
|
+
return false
|
|
710
|
+
})
|
|
711
|
+
return out
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const renderShadowPass = (root: Transform, sun: NonNullable<ReturnType<typeof findSun>>): void => {
|
|
715
|
+
const size = sun.shadowsQuality >= 3 ? 4096 : sun.shadowsQuality >= 2 ? 2048 : 1024
|
|
716
|
+
if (!shadowTarget || shadowMapSize !== size) {
|
|
717
|
+
shadowTarget = new RenderTarget(gl, { width: size, height: size, depthTexture: true })
|
|
718
|
+
shadowMapSize = size
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Casters: the walk's caster singles (every hierarchy-visible one, not only the camera-visible —
|
|
722
|
+
// a tree behind the camera still shadows the view) + the batches' `all` streams.
|
|
723
|
+
const batcher = batcherFor(root)
|
|
724
|
+
const meshes = batcher.casterSingles
|
|
725
|
+
|
|
726
|
+
// Fit an ortho frustum around the casters along the sun direction: the singles' transformed
|
|
727
|
+
// AABB corners + the batches' cached world bounds.
|
|
728
|
+
const min = [ Infinity, Infinity, Infinity ], max = [ -Infinity, -Infinity, -Infinity ]
|
|
729
|
+
const corner: number[] = [0, 0, 0]
|
|
730
|
+
for (const mesh of meshes as any[]) {
|
|
731
|
+
const g = mesh.geometry
|
|
732
|
+
if (!g.bounds) g.computeBoundingBox()
|
|
733
|
+
if (!g.bounds || !isFinite(g.bounds.min.x)) continue
|
|
734
|
+
const w = mesh.worldMatrix
|
|
735
|
+
for (let i = 0; i < 8; i++) {
|
|
736
|
+
transformPoint(corner, w,
|
|
737
|
+
i & 1 ? g.bounds.max.x : g.bounds.min.x,
|
|
738
|
+
i & 2 ? g.bounds.max.y : g.bounds.min.y,
|
|
739
|
+
i & 4 ? g.bounds.max.z : g.bounds.min.z, 1)
|
|
740
|
+
for (let k = 0; k < 3; k++) { if (corner[k] < min[k]) min[k] = corner[k]; if (corner[k] > max[k]) max[k] = corner[k] }
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
batcher.casterBounds(min, max)
|
|
744
|
+
if (!isFinite(min[0])) { sceneUniforms.shadowEnabled.value = 0; return }
|
|
745
|
+
const [ minX, minY, minZ ] = min, [ maxX, maxY, maxZ ] = max
|
|
746
|
+
|
|
747
|
+
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2
|
|
748
|
+
const radius = Math.max(0.5, Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2)
|
|
749
|
+
|
|
750
|
+
// Light view: looking along the sun direction at the caster bounds center.
|
|
751
|
+
let dx = sun.direction[0], dy = sun.direction[1], dz = sun.direction[2]
|
|
752
|
+
const dl = Math.hypot(dx, dy, dz) || 1
|
|
753
|
+
dx /= dl; dy /= dl; dz /= dl
|
|
754
|
+
const eye = [cx - dx * radius * 2, cy - dy * radius * 2, cz - dz * radius * 2]
|
|
755
|
+
// Build an orthonormal basis (z = -dir → light looks down +dir).
|
|
756
|
+
const zx = -dx, zy = -dy, zz = -dz
|
|
757
|
+
const upY = Math.abs(dy) > 0.99 ? [1, 0, 0] : [0, 1, 0]
|
|
758
|
+
let xx = upY[1] * zz - upY[2] * zy, xy = upY[2] * zx - upY[0] * zz, xz = upY[0] * zy - upY[1] * zx
|
|
759
|
+
const xl = Math.hypot(xx, xy, xz) || 1; xx /= xl; xy /= xl; xz /= xl
|
|
760
|
+
const yx = zy * xz - zz * xy, yy = zz * xx - zx * xz, yz = zx * xy - zy * xx
|
|
761
|
+
// View matrix = inverse of the light's world matrix (rigid).
|
|
762
|
+
const view = new Float32Array(16)
|
|
763
|
+
view[0] = xx; view[4] = xy; view[8] = xz
|
|
764
|
+
view[1] = yx; view[5] = yy; view[9] = yz
|
|
765
|
+
view[2] = zx; view[6] = zy; view[10] = zz
|
|
766
|
+
view[12] = -(xx * eye[0] + xy * eye[1] + xz * eye[2])
|
|
767
|
+
view[13] = -(yx * eye[0] + yy * eye[1] + yz * eye[2])
|
|
768
|
+
view[14] = -(zx * eye[0] + zy * eye[1] + zz * eye[2])
|
|
769
|
+
view[15] = 1
|
|
770
|
+
const proj = orthographic(-radius, radius, -radius, radius, 0.05, radius * 4)
|
|
771
|
+
const viewProj = multiply(new Float32Array(16), proj, view)
|
|
772
|
+
|
|
773
|
+
depthProgram.uniforms.shadowViewProjection.value = viewProj
|
|
774
|
+
depthSkinProgram.uniforms.shadowViewProjection.value = viewProj
|
|
775
|
+
depthInstancedProgram.uniforms.shadowViewProjection.value = viewProj
|
|
776
|
+
sceneUniforms.shadowMatrix.value = viewProj
|
|
777
|
+
sceneUniforms.shadowMap.value = shadowTarget.depthTexture
|
|
778
|
+
sceneUniforms.shadowMapSize.value = size
|
|
779
|
+
sceneUniforms.shadowEnabled.value = 1
|
|
780
|
+
|
|
781
|
+
renderer.bindFramebuffer(shadowTarget)
|
|
782
|
+
renderer.setViewport(size, size)
|
|
783
|
+
gl.clearColor(1, 1, 1, 1)
|
|
784
|
+
renderer.enable(gl.DEPTH_TEST)
|
|
785
|
+
renderer.setDepthMask(true)
|
|
786
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
|
787
|
+
|
|
788
|
+
const identityMat = identity()
|
|
789
|
+
for (const mesh of meshes as any[]) {
|
|
790
|
+
const isSkin = mesh instanceof GLTFSkin
|
|
791
|
+
const program = isSkin ? depthSkinProgram : depthProgram
|
|
792
|
+
const original = mesh.program
|
|
793
|
+
program.uniforms.modelMatrix.value = isSkin ? identityMat : mesh.worldMatrix
|
|
794
|
+
mesh.program = program
|
|
795
|
+
mesh.draw({})
|
|
796
|
+
mesh.program = original
|
|
797
|
+
}
|
|
798
|
+
batcher.drawShadow(depthInstancedProgram)
|
|
799
|
+
renderer.bindFramebuffer({ buffer: null } as any)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const stepFrame = (nowMs: number): void => {
|
|
803
|
+
let dt = lastTime === 0 ? 0 : (nowMs - lastTime) / 1000
|
|
804
|
+
if (dt > 0.05) dt = 0.05
|
|
805
|
+
lastTime = nowMs
|
|
806
|
+
const t = nowMs / 1000
|
|
807
|
+
userTime.value[0] = t
|
|
808
|
+
userTime.value[1] = t - Math.floor(t)
|
|
809
|
+
// The core runs early update → physics → animators (joint writes + morphWeights) → late update.
|
|
810
|
+
core.tick(dt)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const syncCamera = (sceneId: number): void => {
|
|
814
|
+
const aspect = canvas.width / Math.max(1, canvas.height)
|
|
815
|
+
const { fov, near, far } = core.projectionOf(sceneId)
|
|
816
|
+
perspScale.value = Math.max(1, canvas.height) / (2 * Math.tan((fov * Math.PI) / 360))
|
|
817
|
+
if (aspect !== lastAspect || fov !== lastFov || near !== lastNear || far !== lastFar) {
|
|
818
|
+
lastAspect = aspect
|
|
819
|
+
lastFov = fov; lastNear = near; lastFar = far
|
|
820
|
+
camera.perspective({ fov, aspect, near, far })
|
|
821
|
+
}
|
|
822
|
+
const camId = core.scenes.get(sceneId)?.camera
|
|
823
|
+
const world = camId !== undefined ? core.worldMatrix(camId) : identity()
|
|
824
|
+
for (let i = 0; i < 16; i++) (camera.matrix as any)[i] = world[i]
|
|
825
|
+
camera.worldMatrixNeedsUpdate = true
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const renderFrame = (nowMs: number): boolean => {
|
|
829
|
+
const sceneId = core.activeSceneId()
|
|
830
|
+
const scene = core.scenes.get(sceneId)
|
|
831
|
+
const root = sceneRoots.get(sceneId)
|
|
832
|
+
if (!scene || !root) return false
|
|
833
|
+
stepFrame(nowMs)
|
|
834
|
+
updateVideoTextures()
|
|
835
|
+
syncCamera(sceneId)
|
|
836
|
+
|
|
837
|
+
// Wireframe companions track late-arriving drawables; polygon offset pushes the shaded fill
|
|
838
|
+
// back so overlay lines win the depth tie (fill-only state — lines/points are unaffected, and
|
|
839
|
+
// during the shadow pass it just adds a hair of depth bias).
|
|
840
|
+
if (wireframeMode !== 'off') {
|
|
841
|
+
updateWireframe()
|
|
842
|
+
gl.enable(gl.POLYGON_OFFSET_FILL)
|
|
843
|
+
gl.polygonOffset(1, 1)
|
|
844
|
+
} else {
|
|
845
|
+
gl.disable(gl.POLYGON_OFFSET_FILL)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Sun + shadows. The batcher walks the graph once here; the shadow pass and the main pass
|
|
849
|
+
// both draw from what it collected.
|
|
850
|
+
updateWorld(root)
|
|
851
|
+
const batcher = batcherFor(root)
|
|
852
|
+
batcher.collect(root, camera, true, graphVersion)
|
|
853
|
+
const sun = findSun(sceneId)
|
|
854
|
+
if (sun) {
|
|
855
|
+
// Native passes HEX_TO_RGB straight to LightManager::Builder::color (linear) — no sRGB decode.
|
|
856
|
+
const r = ((sun.color >>> 16) & 0xff) / 255, g = ((sun.color >>> 8) & 0xff) / 255, b = (sun.color & 0xff) / 255
|
|
857
|
+
sceneUniforms.sunDirection.value = sun.direction as unknown as number[]
|
|
858
|
+
sceneUniforms.sunColorIntensity.value = [r, g, b, sun.intensity]
|
|
859
|
+
if (sun.shadowsQuality > 0) renderShadowPass(root, sun)
|
|
860
|
+
else sceneUniforms.shadowEnabled.value = 0
|
|
861
|
+
} else {
|
|
862
|
+
sceneUniforms.sunColorIntensity.value = [1, 1, 1, 0]
|
|
863
|
+
sceneUniforms.shadowEnabled.value = 0
|
|
864
|
+
}
|
|
865
|
+
sceneUniforms.iblLuminance.value = scene.ibl ?? 0
|
|
866
|
+
|
|
867
|
+
// Skybox clear color: native treats the hex as a LINEAR color rendered through the tonemapper;
|
|
868
|
+
// the default (no setSkybox) is Filament's default skybox color.
|
|
869
|
+
const clear = scene.skybox !== undefined
|
|
870
|
+
? [tonemapJs(((scene.skybox >>> 16) & 0xff) / 255), tonemapJs(((scene.skybox >>> 8) & 0xff) / 255), tonemapJs((scene.skybox & 0xff) / 255)]
|
|
871
|
+
: [tonemapJs(0.1), tonemapJs(0.25), tonemapJs(0.57)]
|
|
872
|
+
|
|
873
|
+
// Main pass. Transparent hosts leave alpha 0 everywhere the scene doesn't draw — MSAA edge
|
|
874
|
+
// coverage lands as premultiplied (color × coverage, coverage), which is what the canvas declares.
|
|
875
|
+
renderer.bindFramebuffer({ buffer: null } as any)
|
|
876
|
+
renderer.setViewport(canvas.width, canvas.height)
|
|
877
|
+
if (options.transparent) gl.clearColor(0, 0, 0, 0)
|
|
878
|
+
else gl.clearColor(clear[0], clear[1], clear[2], 1)
|
|
879
|
+
renderer.enable(gl.DEPTH_TEST)
|
|
880
|
+
renderer.setDepthMask(true)
|
|
881
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
|
882
|
+
batcher.drawMain(camera, sceneUniforms.shadowEnabled)
|
|
883
|
+
|
|
884
|
+
// Editor grid (if connected + enabled): depth-tested against the scene content just drawn.
|
|
885
|
+
// It gets the skybox color even on a transparent host — that's the backdrop the scene declares,
|
|
886
|
+
// and the grid inks itself against it (dark lines on a light sky, light lines on a dark one).
|
|
887
|
+
editorLayer?.renderScenePass(clear)
|
|
888
|
+
|
|
889
|
+
// Overlays: fresh depth, blended over (View::BlendMode::TRANSLUCENT equivalent)
|
|
890
|
+
for (const overlayId of scene.overlays) {
|
|
891
|
+
const overlay = sceneRoots.get(overlayId)
|
|
892
|
+
if (!overlay) continue
|
|
893
|
+
gl.clear(gl.DEPTH_BUFFER_BIT)
|
|
894
|
+
renderScene(overlay, camera)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Editor selection outline + transform gizmo — always on top.
|
|
898
|
+
editorLayer?.renderOverlayPass()
|
|
899
|
+
// Editor camera preview inset (a CameraPlace's view) — last, in its own rect.
|
|
900
|
+
editorLayer?.renderPreviewPass(root, clear)
|
|
901
|
+
return true
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// A throwing frame must not kill the loop permanently — log each distinct error once and keep going.
|
|
905
|
+
const frameErrors = new Set<string>()
|
|
906
|
+
const loop = (): void => {
|
|
907
|
+
if (!sceneOpened) return
|
|
908
|
+
try {
|
|
909
|
+
renderFrame(performance.now())
|
|
910
|
+
} catch (e) {
|
|
911
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
912
|
+
if (!frameErrors.has(msg)) {
|
|
913
|
+
frameErrors.add(msg)
|
|
914
|
+
console.error('[viewer-lite] render frame failed:', e)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
requestAnimationFrame(loop)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ---- raycast (port of collider.cpp) ------------------------------------------------------------
|
|
921
|
+
|
|
922
|
+
const sceneIdOf = (id: number): number | undefined => core.sceneOf(id)?.id
|
|
923
|
+
|
|
924
|
+
let cameraRayOrigin = [0, 0, 0]
|
|
925
|
+
const getViewRay = (sceneId: number, screenX: number, screenY: number): { origin: number[], dir: number[] } => {
|
|
926
|
+
const camId = core.scenes.get(sceneId)?.camera
|
|
927
|
+
const w = camId !== undefined ? core.worldMatrix(camId) : identity()
|
|
928
|
+
const width = Math.max(1, canvas.width), height = Math.max(1, canvas.height)
|
|
929
|
+
const aspect = width / height
|
|
930
|
+
// Screen inputs are logical px; the backing store is logical × resFactor → scale up to match.
|
|
931
|
+
const sx = screenX * resFactor, sy = screenY * resFactor
|
|
932
|
+
const t = Math.tan((core.projectionOf(sceneId).fov * Math.PI) / 360)
|
|
933
|
+
const cx = ((sx / width) * 2 - 1) * t * aspect
|
|
934
|
+
const cy = (1 - (sy / height) * 2) * t
|
|
935
|
+
const dx = w[0] * cx + w[4] * cy - w[8]
|
|
936
|
+
const dy = w[1] * cx + w[5] * cy - w[9]
|
|
937
|
+
const dz = w[2] * cx + w[6] * cy - w[10]
|
|
938
|
+
const len = Math.hypot(dx, dy, dz) || 1
|
|
939
|
+
cameraRayOrigin = [w[12], w[13], w[14]]
|
|
940
|
+
return { origin: cameraRayOrigin, dir: [dx / len, dy / len, dz / len] }
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const raycastColliders = (sceneId: number, origin: number[], dir: number[]): number => {
|
|
944
|
+
const colliders = sceneColliders.get(sceneId)
|
|
945
|
+
if (!colliders) return 0
|
|
946
|
+
let minDistance = Infinity
|
|
947
|
+
let target = 0
|
|
948
|
+
const localO: number[] = [0, 0, 0], localD: number[] = [0, 0, 0]
|
|
949
|
+
for (const [entityId, collider] of colliders) {
|
|
950
|
+
if (!core.entities.has(entityId) || !core.methods.isVisible(entityId)) continue
|
|
951
|
+
const inv = invertTRS(core.worldMatrixF(entityId))
|
|
952
|
+
transformPoint(localO, inv, origin[0], origin[1], origin[2], 1)
|
|
953
|
+
transformPoint(localD, inv, dir[0], dir[1], dir[2], 0)
|
|
954
|
+
const d = collider.type === 'box'
|
|
955
|
+
? intersectRayBox(collider.center, collider.extent, localO, localD)
|
|
956
|
+
: intersectRaySphere(collider.center, collider.radius, localO, localD)
|
|
957
|
+
if (d > 0 && d < minDistance) { minDistance = d; target = entityId }
|
|
958
|
+
}
|
|
959
|
+
return target
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const raycastRenderables = (sceneId: number, origin: number[], dir: number[]): number => {
|
|
963
|
+
const root = sceneRoots.get(sceneId)
|
|
964
|
+
if (!root) return 0
|
|
965
|
+
let minDistance = Infinity
|
|
966
|
+
let target = 0
|
|
967
|
+
const localO: number[] = [0, 0, 0], localD: number[] = [0, 0, 0]
|
|
968
|
+
root.updateMatrixWorld()
|
|
969
|
+
for (const mesh of collectMeshes(root) as any[]) {
|
|
970
|
+
const g = mesh.geometry
|
|
971
|
+
if (!g.bounds) g.computeBoundingBox()
|
|
972
|
+
if (!g.bounds || !isFinite(g.bounds.min.x)) continue
|
|
973
|
+
const inv = invertTRS(mesh.worldMatrix)
|
|
974
|
+
transformPoint(localO, inv, origin[0], origin[1], origin[2], 1)
|
|
975
|
+
transformPoint(localD, inv, dir[0], dir[1], dir[2], 0)
|
|
976
|
+
const c = [
|
|
977
|
+
(g.bounds.min.x + g.bounds.max.x) / 2, (g.bounds.min.y + g.bounds.max.y) / 2, (g.bounds.min.z + g.bounds.max.z) / 2,
|
|
978
|
+
]
|
|
979
|
+
const e = [
|
|
980
|
+
(g.bounds.max.x - g.bounds.min.x) / 2, (g.bounds.max.y - g.bounds.min.y) / 2, (g.bounds.max.z - g.bounds.min.z) / 2,
|
|
981
|
+
]
|
|
982
|
+
const d = intersectRayBox(c, e, localO, localD)
|
|
983
|
+
if (d > 0 && d < minDistance) {
|
|
984
|
+
minDistance = d
|
|
985
|
+
target = (mesh as any)._entId ?? 0
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return target
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Pick order mirrors collider.cpp raycastView: overlay colliders (reverse) → scene colliders.
|
|
992
|
+
const raycastView = (screenX: number, screenY: number): number => {
|
|
993
|
+
const sceneId = core.activeSceneId()
|
|
994
|
+
const scene = core.scenes.get(sceneId)
|
|
995
|
+
if (!scene || scene.camera === undefined) return 0
|
|
996
|
+
const { origin, dir } = getViewRay(sceneId, screenX, screenY)
|
|
997
|
+
for (let i = scene.overlays.length - 1; i >= 0; i--) {
|
|
998
|
+
const hit = raycastColliders(scene.overlays[i], origin, dir)
|
|
999
|
+
if (hit !== 0) return hit
|
|
1000
|
+
}
|
|
1001
|
+
return raycastColliders(sceneId, origin, dir)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ---- particle systems (creator-particles' streams → dynamic drawables) -----------------------
|
|
1005
|
+
// One drawable per system under the emitter's transform: points / quads / ribbons share ONE
|
|
1006
|
+
// interleaved buffer (the stream as is, re-uploaded per frame); a mesh system is unpacked into
|
|
1007
|
+
// position / normal / uv for the lit / unlit programs (the frame's tangent quaternion → normal).
|
|
1008
|
+
// The index buffer is the mode's pattern for the capacity, rebuilt with the geometry version.
|
|
1009
|
+
const disposeParticles = (rec: GlParticles): void => {
|
|
1010
|
+
if (rec.mesh) rec.mesh.setParent(null as any)
|
|
1011
|
+
if (rec.buffer) gl.deleteBuffer(rec.buffer)
|
|
1012
|
+
rec.mesh = null; rec.geometry = null; rec.buffer = null
|
|
1013
|
+
}
|
|
1014
|
+
const particleProgram = (rec: GlParticles, mode: number): Program => {
|
|
1015
|
+
const mat = glMaterials.get(rec.materialId)
|
|
1016
|
+
if (mode === MODE.MESH) return (mat ?? makeMaterial('unlit')).program
|
|
1017
|
+
const wantQuad = mode !== MODE.POINTS
|
|
1018
|
+
if (mat && (mat.kind === (wantQuad ? 'particles-quad' : 'particles'))) return mat.program
|
|
1019
|
+
// a material of another family (custom, lit) cannot read the stream — draw with the default look
|
|
1020
|
+
return makeMaterial(wantQuad ? 'particles-quad' : 'particles').program
|
|
1021
|
+
}
|
|
1022
|
+
const buildParticleGeometry = (entityId: number, rec: GlParticles, frame: ParticleFrame): void => {
|
|
1023
|
+
disposeParticles(rec)
|
|
1024
|
+
rec.version = frame.geometryVersion
|
|
1025
|
+
rec.mode = frame.mode
|
|
1026
|
+
if (frame.capacity === 0) return
|
|
1027
|
+
const pattern = core.particleIndexPattern(entityId)
|
|
1028
|
+
if (pattern.length === 0) return
|
|
1029
|
+
let geometry: Geometry
|
|
1030
|
+
if (frame.mode === MODE.MESH) {
|
|
1031
|
+
const n = frame.capacity * frame.meshVertexCount
|
|
1032
|
+
geometry = new Geometry(gl, {
|
|
1033
|
+
position: { size: 3, data: new Float32Array(n * 3), usage: gl.DYNAMIC_DRAW },
|
|
1034
|
+
normal: { size: 3, data: new Float32Array(n * 3), usage: gl.DYNAMIC_DRAW },
|
|
1035
|
+
uv: { size: 2, data: new Float32Array(n * 2), usage: gl.DYNAMIC_DRAW },
|
|
1036
|
+
index: { data: pattern },
|
|
1037
|
+
} as any)
|
|
1038
|
+
} else {
|
|
1039
|
+
const buffer = gl.createBuffer()!
|
|
1040
|
+
rec.buffer = buffer
|
|
1041
|
+
const stride = frame.stride
|
|
1042
|
+
const count = frame.mode === MODE.POINTS ? frame.capacity : frame.mode === MODE.QUADS ? frame.capacity * 4 : (frame.capacity + 1) * 2
|
|
1043
|
+
const attr = (size: number, offset: number) => ({ size, type: gl.FLOAT, buffer, stride, offset, count, data: new Float32Array(0) })
|
|
1044
|
+
const attrs: Record<string, unknown> = { position: attr(3, 0), color: attr(4, 12), custom: attr(4, 28) }
|
|
1045
|
+
if (frame.mode !== MODE.POINTS) { attrs.aux = attr(3, 44); attrs.corner = attr(2, 56) }
|
|
1046
|
+
attrs.index = { data: Uint16Array.from(pattern) }
|
|
1047
|
+
geometry = new Geometry(gl, attrs as any)
|
|
1048
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
|
1049
|
+
gl.bufferData(gl.ARRAY_BUFFER, count * stride, gl.DYNAMIC_DRAW)
|
|
1050
|
+
;(renderer.state as any).boundBuffer = buffer
|
|
1051
|
+
}
|
|
1052
|
+
const mesh = new Mesh(gl, { geometry, program: particleProgram(rec, frame.mode), mode: frame.mode === MODE.POINTS ? gl.POINTS : gl.TRIANGLES })
|
|
1053
|
+
mesh.frustumCulled = false
|
|
1054
|
+
mesh.matrixAutoUpdate = false
|
|
1055
|
+
;(mesh as any)._entId = entityId
|
|
1056
|
+
;(mesh as any)._particles = true
|
|
1057
|
+
mesh.setParent(glEnt(entityId).transform)
|
|
1058
|
+
rec.geometry = geometry
|
|
1059
|
+
rec.mesh = mesh
|
|
1060
|
+
}
|
|
1061
|
+
const uploadParticles = (entityId: number, frame: ParticleFrame): void => {
|
|
1062
|
+
let rec = glParticles.get(entityId)
|
|
1063
|
+
if (!rec) { rec = { materialId: 0, mesh: null, geometry: null, buffer: null, version: 0, mode: -1 }; glParticles.set(entityId, rec) }
|
|
1064
|
+
if (rec.version !== frame.geometryVersion || rec.mode !== frame.mode || !rec.mesh) { buildParticleGeometry(entityId, rec, frame); touchGraph() }
|
|
1065
|
+
if (!rec.mesh || !rec.geometry) return
|
|
1066
|
+
const show = frame.indexCount > 0
|
|
1067
|
+
if (rec.mesh.visible !== show) { rec.mesh.visible = show; touchGraph() }
|
|
1068
|
+
if (!show) return
|
|
1069
|
+
rec.mesh.renderOrder = frame.order
|
|
1070
|
+
if (frame.mode === MODE.MESH) {
|
|
1071
|
+
const n = frame.vertexCount
|
|
1072
|
+
const f = new Float32Array(frame.bytes.buffer, frame.bytes.byteOffset, n * 10)
|
|
1073
|
+
const pos = rec.geometry.attributes.position, nrm = rec.geometry.attributes.normal, uv = rec.geometry.attributes.uv
|
|
1074
|
+
const P = pos.data as Float32Array, N = nrm.data as Float32Array, U = uv.data as Float32Array
|
|
1075
|
+
for (let i = 0; i < n; i++) {
|
|
1076
|
+
const o = i * 10
|
|
1077
|
+
P[i * 3] = f[o]; P[i * 3 + 1] = f[o + 1]; P[i * 3 + 2] = f[o + 2]
|
|
1078
|
+
// the tangent frame's +z (the vertex normal): rotate (0, 0, 1) by the quaternion
|
|
1079
|
+
const x = f[o + 3], y = f[o + 4], z = f[o + 5], w = f[o + 6]
|
|
1080
|
+
N[i * 3] = 2 * (x * z + y * w); N[i * 3 + 1] = 2 * (y * z - x * w); N[i * 3 + 2] = 1 - 2 * (x * x + y * y)
|
|
1081
|
+
U[i * 2] = f[o + 8]; U[i * 2 + 1] = f[o + 9]
|
|
1082
|
+
}
|
|
1083
|
+
pos.needsUpdate = nrm.needsUpdate = uv.needsUpdate = true
|
|
1084
|
+
} else if (rec.buffer) {
|
|
1085
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, rec.buffer)
|
|
1086
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, frame.bytes)
|
|
1087
|
+
;(renderer.state as any).boundBuffer = rec.buffer
|
|
1088
|
+
}
|
|
1089
|
+
rec.geometry.drawRange.start = 0
|
|
1090
|
+
rec.geometry.drawRange.count = frame.indexCount
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ---- SceneBackend hooks (core → GL reactions) -------------------------------------------------
|
|
1094
|
+
|
|
1095
|
+
const backend: SceneBackend = {
|
|
1096
|
+
allocMatrix: (id) => glEnt(id).transform.matrix as unknown as Mat4Like,
|
|
1097
|
+
localMatrixChanged: (id) => { glEnt(id).transform.worldMatrixNeedsUpdate = true; touchGraph() },
|
|
1098
|
+
parentChanged: (childId, parentId) => {
|
|
1099
|
+
touchGraph()
|
|
1100
|
+
const child = glEnt(childId).transform
|
|
1101
|
+
if (parentId !== 0) child.setParent(glEnt(parentId).transform)
|
|
1102
|
+
else {
|
|
1103
|
+
// Detach → back under the owning scene's root (if a member), else orphaned.
|
|
1104
|
+
const s = core.sceneOf(childId)
|
|
1105
|
+
child.setParent(((s && sceneRoots.get(s.id)) ?? null) as any)
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
sceneCreated: (sceneId) => { sceneRoots.set(sceneId, new Transform()); sceneColliders.set(sceneId, new Map()) },
|
|
1109
|
+
entitySceneChanged: (id, sceneId, added) => {
|
|
1110
|
+
touchGraph()
|
|
1111
|
+
const root = sceneRoots.get(sceneId)
|
|
1112
|
+
if (!root) return
|
|
1113
|
+
const g = glEnt(id)
|
|
1114
|
+
if (added) {
|
|
1115
|
+
// Move any collider bookkeeping to this scene (scene.cpp move_elements; colliders are web-side).
|
|
1116
|
+
for (const [sid, cmap] of sceneColliders) {
|
|
1117
|
+
if (sid === sceneId) continue
|
|
1118
|
+
const col = cmap.get(id)
|
|
1119
|
+
if (col) { sceneColliders.get(sceneId)!.set(id, col); cmap.delete(id) }
|
|
1120
|
+
}
|
|
1121
|
+
if (core.ent(id).parent === 0) g.transform.setParent(root)
|
|
1122
|
+
} else {
|
|
1123
|
+
if (core.ent(id).parent === 0 && g.transform.parent === root) g.transform.setParent(null as any)
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
visibleChanged: (id, visible) => { glEnt(id).transform.visible = visible; touchGraph() },
|
|
1127
|
+
materialCreated: (id, rec, systemId) => { glMaterials.set(id, buildProgramFor(rec.shader, systemId)) },
|
|
1128
|
+
uniformChanged: (materialId, uniform, u) => applyUniform(materialId, uniform, u),
|
|
1129
|
+
meshSet: (entityId, materialId, mesh, arrays) => { buildMesh(entityId, materialId, arrays, mesh.kind); touchGraph() },
|
|
1130
|
+
glbAttach: (systemId, rootId, entIds) => attachGlb(systemId, rootId, entIds).then(touchGraph),
|
|
1131
|
+
// Animator-sampled blendshape weights → the node's weight array from buildMorphState (glb.ts)
|
|
1132
|
+
morphWeights: (entityId, weights) => {
|
|
1133
|
+
const w = (glEnt(entityId).transform as any).weights as Float32Array | undefined
|
|
1134
|
+
if (w) w.set(weights.subarray(0, Math.min(w.length, weights.length)))
|
|
1135
|
+
},
|
|
1136
|
+
entityDestroyed: (id) => {
|
|
1137
|
+
touchGraph()
|
|
1138
|
+
const g = glEnts.get(id)
|
|
1139
|
+
if (g) { g.transform.setParent(null as any); glEnts.delete(id) }
|
|
1140
|
+
for (const cmap of sceneColliders.values()) cmap.delete(id)
|
|
1141
|
+
},
|
|
1142
|
+
entityCloned: (sourceId, cloneId) => { cloneDrawables(sourceId, cloneId); touchGraph() },
|
|
1143
|
+
subtreeCloned: (_sourceRootId, _cloneRootId, map) => rebindClonedSkins(map),
|
|
1144
|
+
particleSystemCreated: (entityId, materialId) => {
|
|
1145
|
+
const rec = glParticles.get(entityId)
|
|
1146
|
+
if (rec) { disposeParticles(rec); rec.materialId = materialId; rec.version = 0 }
|
|
1147
|
+
else glParticles.set(entityId, { materialId, mesh: null, geometry: null, buffer: null, version: 0, mode: -1 })
|
|
1148
|
+
},
|
|
1149
|
+
particlesFrame: (entityId, frame) => uploadParticles(entityId, frame),
|
|
1150
|
+
particleSystemDestroyed: (entityId) => {
|
|
1151
|
+
const rec = glParticles.get(entityId)
|
|
1152
|
+
if (rec) { disposeParticles(rec); glParticles.delete(entityId) }
|
|
1153
|
+
touchGraph()
|
|
1154
|
+
},
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// The animation runtime (creator-anim wasm) must be resident before the first animator call.
|
|
1158
|
+
// The animation + physics runtimes (creator-anim / creator-physics wasm, base64-embedded in the bundle):
|
|
1159
|
+
// resident before the scene core exists, so the SDK's first call finds them ready.
|
|
1160
|
+
await Promise.all([loadCreatorAnim(), loadCreatorPhysics(), loadCreatorParticles()])
|
|
1161
|
+
const core = createSceneCore(backend, {
|
|
1162
|
+
getBuffer: getFetchedBuffer,
|
|
1163
|
+
getSourceName: getLocalName,
|
|
1164
|
+
width: canvas.width || 1,
|
|
1165
|
+
height: canvas.height || 1,
|
|
1166
|
+
fov: FOV,
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
// ---- the bridge: shared scene-graph (core.methods) + GL-only / capability methods --------------
|
|
1170
|
+
|
|
1171
|
+
// PNG/JPG/WebP… via the browser decoder → sRGB8_A8 texture.
|
|
1172
|
+
const createBitmapTexture = (blob: Blob, onComplete: (id: number, width: number, height: number) => void, onReject: () => void): void => {
|
|
1173
|
+
createImageBitmap(blob)
|
|
1174
|
+
.then((bitmap) => {
|
|
1175
|
+
const texture = new Texture(gl, {
|
|
1176
|
+
image: bitmap as any,
|
|
1177
|
+
internalFormat: gl.SRGB8_ALPHA8,
|
|
1178
|
+
format: gl.RGBA,
|
|
1179
|
+
generateMipmaps: false,
|
|
1180
|
+
flipY: false,
|
|
1181
|
+
premultiplyAlpha: false,
|
|
1182
|
+
})
|
|
1183
|
+
const id = nextTexture++
|
|
1184
|
+
glTextures.set(id, texture)
|
|
1185
|
+
onComplete(id, bitmap.width, bitmap.height)
|
|
1186
|
+
})
|
|
1187
|
+
.catch(onReject)
|
|
1188
|
+
}
|
|
1189
|
+
const bridge = {
|
|
1190
|
+
// the contract's completeness layer (generated): every required method a stub UNDER ours
|
|
1191
|
+
...unsupported,
|
|
1192
|
+
...basePropsLite,
|
|
1193
|
+
...core.methods,
|
|
1194
|
+
|
|
1195
|
+
// --- the OWNED creators (docs/owned.md): the SDK keeps the Handle and puts `_h.id` on the wire. The
|
|
1196
|
+
// lite engine keeps no owned records (a drop frees nothing), so it is a plain `{ id }` over the
|
|
1197
|
+
// core's id — the wrap creator-gl's generated bridge does. createTexture* below return one too ---
|
|
1198
|
+
createEntity: (): Handle => ({ id: core.methods.createEntity() }),
|
|
1199
|
+
cloneEntity: (entityId: number): Handle => ({ id: core.methods.cloneEntity(entityId) }),
|
|
1200
|
+
createMaterial: (systemId: number): Handle => ({ id: core.methods.createMaterial(systemId) }),
|
|
1201
|
+
// Never null: a name this engine has no program for draws unlit (buildProgramFor), as it always did.
|
|
1202
|
+
builtinMaterial: (name: string): Handle | null => ({ id: core.methods.createMaterialNamed(name) }),
|
|
1203
|
+
createGlb(systemId: number, onComplete: (root: Handle | null) => void, onReject: () => void): void {
|
|
1204
|
+
core.methods.createGlb(systemId, (root) => onComplete({ id: root }), onReject)
|
|
1205
|
+
},
|
|
1206
|
+
|
|
1207
|
+
// --- scene lifecycle (core sets active scene; web adds the render loop + canvas display) ---
|
|
1208
|
+
openScene(sceneId: number): void {
|
|
1209
|
+
const wasOpen = sceneOpened
|
|
1210
|
+
core.methods.openScene(sceneId)
|
|
1211
|
+
sceneOpened = core.scenes.has(sceneId)
|
|
1212
|
+
canvas.style.display = 'block'
|
|
1213
|
+
if (!wasOpen && sceneOpened) requestAnimationFrame(loop)
|
|
1214
|
+
},
|
|
1215
|
+
closeScene(): void {
|
|
1216
|
+
canvas.style.display = 'none'
|
|
1217
|
+
sceneOpened = false
|
|
1218
|
+
core.methods.closeScene()
|
|
1219
|
+
},
|
|
1220
|
+
launchAR(): void { throw new Error('launchAR is not implemented') },
|
|
1221
|
+
stopAR(): void { throw new Error('stopAR is not implemented') },
|
|
1222
|
+
launchVR(_sceneId: number, _onComplete: () => void, onReject: (err?: unknown) => void): void {
|
|
1223
|
+
onReject(new Error('VR is not supported on this device'))
|
|
1224
|
+
},
|
|
1225
|
+
stopVR(): void {},
|
|
1226
|
+
async warmRender(_sceneId: number, onComplete: () => void): Promise<void> { onComplete() },
|
|
1227
|
+
|
|
1228
|
+
// --- camera (dynamic canvas viewport; web ignores the projection callback like the Filament host) ---
|
|
1229
|
+
attachCameraToAR(sceneId: number, cameraEntityId: number, _onProjectionChange: unknown): void {
|
|
1230
|
+
const s = core.scenes.get(sceneId)
|
|
1231
|
+
if (!s) return
|
|
1232
|
+
s.camera = cameraEntityId
|
|
1233
|
+
for (const overlayId of s.overlays) { const o = core.scenes.get(overlayId); if (o) o.camera = cameraEntityId }
|
|
1234
|
+
},
|
|
1235
|
+
// core.methods carries setCameraProjection (per-scene state); this override only re-derives the
|
|
1236
|
+
// HORIZONTAL fov against the live canvas aspect instead of the core's fixed width/height.
|
|
1237
|
+
getCameraFov(sceneId: number, fovType: number): number {
|
|
1238
|
+
const fov = core.projectionOf(sceneId).fov
|
|
1239
|
+
if (fovType !== 1) return fov
|
|
1240
|
+
const vert = (fov * Math.PI) / 180
|
|
1241
|
+
const aspect = canvas.width / Math.max(1, canvas.height)
|
|
1242
|
+
return (2 * Math.atan(Math.tan(vert / 2) * aspect) * 180) / Math.PI
|
|
1243
|
+
},
|
|
1244
|
+
getCameraViewDirection(sceneId: number, screenX: number, screenY: number, out: Float32Array): void {
|
|
1245
|
+
if (!core.scenes.has(sceneId)) { out[0] = 0; out[1] = 0; out[2] = -1; return }
|
|
1246
|
+
const { dir } = getViewRay(sceneId, screenX, screenY)
|
|
1247
|
+
out[0] = dir[0]; out[1] = dir[1]; out[2] = dir[2]
|
|
1248
|
+
},
|
|
1249
|
+
// Logical (CSS-px) viewport — device.pixelRatio carries the density separately. renderer.width/
|
|
1250
|
+
// height hold the logical size passed to setSize (backing is that × dpr).
|
|
1251
|
+
getDisplaySize: (): Float32Array => Float32Array.of(renderer.width || canvas.width, renderer.height || canvas.height),
|
|
1252
|
+
|
|
1253
|
+
// --- textures (GL) ---
|
|
1254
|
+
createTexture(systemId: number, onComplete: (texture: Handle, width: number, height: number) => void, onReject: () => void): void {
|
|
1255
|
+
const done = (id: number, width: number, height: number) => onComplete({ id }, width, height)
|
|
1256
|
+
const blob = getBlob(systemId)
|
|
1257
|
+
// KTX2 (Basis Universal): the browser can't decode it — route through the same lazy basis
|
|
1258
|
+
// worker GLBs use (ASTC/BPTC/S3TC, else an rgba32 bitmap). sRGB vs linear from the file's DFD.
|
|
1259
|
+
blob.slice(0, 12).arrayBuffer().then(async (head) => {
|
|
1260
|
+
const decoders = await import('./decoders')
|
|
1261
|
+
if (!decoders.isKtx2Bytes(new Uint8Array(head))) return false
|
|
1262
|
+
const buf = await blob.arrayBuffer()
|
|
1263
|
+
const parsed = await decoders.transcodeKtx2(gl, buf)
|
|
1264
|
+
const srgb = decoders.ktx2IsSrgb(buf)
|
|
1265
|
+
let texture: Texture
|
|
1266
|
+
if (parsed.isCompressedTexture) {
|
|
1267
|
+
texture = new Texture(gl, {
|
|
1268
|
+
image: parsed,
|
|
1269
|
+
internalFormat: srgb && parsed.srgbInternalFormat ? parsed.srgbInternalFormat : parsed.internalFormat,
|
|
1270
|
+
generateMipmaps: false,
|
|
1271
|
+
minFilter: parsed.length > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR,
|
|
1272
|
+
flipY: false,
|
|
1273
|
+
})
|
|
1274
|
+
} else {
|
|
1275
|
+
texture = new Texture(gl, {
|
|
1276
|
+
image: parsed,
|
|
1277
|
+
internalFormat: srgb ? gl.SRGB8_ALPHA8 : gl.RGBA8,
|
|
1278
|
+
format: gl.RGBA,
|
|
1279
|
+
generateMipmaps: true,
|
|
1280
|
+
flipY: false,
|
|
1281
|
+
premultiplyAlpha: false,
|
|
1282
|
+
})
|
|
1283
|
+
}
|
|
1284
|
+
const id = nextTexture++
|
|
1285
|
+
glTextures.set(id, texture)
|
|
1286
|
+
done(id, parsed.width, parsed.height)
|
|
1287
|
+
return true
|
|
1288
|
+
}).then((handled) => { if (!handled) createBitmapTexture(blob, done, onReject) }, onReject)
|
|
1289
|
+
},
|
|
1290
|
+
// Raw-pixel textures (Texture.fromPixels / a terrain's control map). OGL textures re-upload the
|
|
1291
|
+
// whole image, so a CPU copy per texture takes the sub-rectangle updates.
|
|
1292
|
+
createTexturePixels(width: number, height: number, channels: number, data: Uint8Array, srgb: boolean): Handle {
|
|
1293
|
+
if (width <= 0 || height <= 0 || data.length < width * height * channels) return { id: 0xFFFFFFFF }
|
|
1294
|
+
const rgba = new Uint8Array(width * height * 4)
|
|
1295
|
+
for (let i = 0, n = width * height; i < n; i++) {
|
|
1296
|
+
const s = i * channels, d = i * 4
|
|
1297
|
+
rgba[d] = data[s]
|
|
1298
|
+
rgba[d + 1] = channels >= 2 ? data[s + 1] : data[s]
|
|
1299
|
+
rgba[d + 2] = channels >= 3 ? data[s + 2] : data[s]
|
|
1300
|
+
rgba[d + 3] = channels >= 4 ? data[s + 3] : 255
|
|
1301
|
+
}
|
|
1302
|
+
const texture = new Texture(gl, {
|
|
1303
|
+
image: rgba as any, width, height,
|
|
1304
|
+
internalFormat: srgb ? gl.SRGB8_ALPHA8 : gl.RGBA8, format: gl.RGBA,
|
|
1305
|
+
generateMipmaps: false, flipY: false, premultiplyAlpha: false,
|
|
1306
|
+
minFilter: gl.LINEAR, magFilter: gl.LINEAR,
|
|
1307
|
+
})
|
|
1308
|
+
const id = nextTexture++
|
|
1309
|
+
glTextures.set(id, texture)
|
|
1310
|
+
pixelTextures.set(id, { rgba, width, height, channels })
|
|
1311
|
+
return { id }
|
|
1312
|
+
},
|
|
1313
|
+
updateTexturePixels(textureId: number, x: number, y: number, width: number, height: number, data: Uint8Array): void {
|
|
1314
|
+
const rec = pixelTextures.get(textureId)
|
|
1315
|
+
const texture = glTextures.get(textureId)
|
|
1316
|
+
if (!rec || !texture) return
|
|
1317
|
+
const ch = rec.channels
|
|
1318
|
+
for (let row = 0; row < height; row++) {
|
|
1319
|
+
const ty = y + row
|
|
1320
|
+
if (ty < 0 || ty >= rec.height) continue
|
|
1321
|
+
for (let col = 0; col < width; col++) {
|
|
1322
|
+
const tx = x + col
|
|
1323
|
+
if (tx < 0 || tx >= rec.width) continue
|
|
1324
|
+
const s = (row * width + col) * ch, d = (ty * rec.width + tx) * 4
|
|
1325
|
+
rec.rgba[d] = data[s]
|
|
1326
|
+
rec.rgba[d + 1] = ch >= 2 ? data[s + 1] : data[s]
|
|
1327
|
+
rec.rgba[d + 2] = ch >= 3 ? data[s + 2] : data[s]
|
|
1328
|
+
rec.rgba[d + 3] = ch >= 4 ? data[s + 3] : 255
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
texture.image = rec.rgba as any
|
|
1332
|
+
;(texture as any).needsUpdate = true
|
|
1333
|
+
},
|
|
1334
|
+
createTextureFromCanvas(surfaceId: number): number {
|
|
1335
|
+
const surf = options.canvasHost?.readSurface(surfaceId)
|
|
1336
|
+
if (!surf) return 0
|
|
1337
|
+
const texture = new Texture(gl, {
|
|
1338
|
+
image: new Uint8Array(surf.data.buffer.slice(0)) as any,
|
|
1339
|
+
width: surf.width,
|
|
1340
|
+
height: surf.height,
|
|
1341
|
+
internalFormat: gl.SRGB8_ALPHA8,
|
|
1342
|
+
format: gl.RGBA,
|
|
1343
|
+
generateMipmaps: false,
|
|
1344
|
+
flipY: false,
|
|
1345
|
+
})
|
|
1346
|
+
const id = nextTexture++
|
|
1347
|
+
glTextures.set(id, texture)
|
|
1348
|
+
return id
|
|
1349
|
+
},
|
|
1350
|
+
updateTextureFromCanvas(texId: number, surfaceId: number): void {
|
|
1351
|
+
const surf = options.canvasHost?.readSurface(surfaceId)
|
|
1352
|
+
const texture = glTextures.get(texId)
|
|
1353
|
+
if (!surf || !texture) return
|
|
1354
|
+
texture.image = new Uint8Array(surf.data.buffer.slice(0)) as any
|
|
1355
|
+
texture.width = surf.width
|
|
1356
|
+
texture.height = surf.height
|
|
1357
|
+
;(texture as any).needsUpdate = true
|
|
1358
|
+
},
|
|
1359
|
+
createMediaPlayerTexture(id: number): number {
|
|
1360
|
+
const video = getPlayer(id) as HTMLVideoElement
|
|
1361
|
+
const texture = new Texture(gl, { generateMipmaps: false, flipY: false, premultiplyAlpha: false })
|
|
1362
|
+
activeVideoTextures.push({ video, texture, lastTime: -1 })
|
|
1363
|
+
const texId = nextTexture++
|
|
1364
|
+
glTextures.set(texId, texture)
|
|
1365
|
+
return texId
|
|
1366
|
+
},
|
|
1367
|
+
|
|
1368
|
+
// --- materials that need GL specifics on top of the core's semantic recording ---
|
|
1369
|
+
setUniformTexture(materialId: number, uniform: string, systemId: number, wrapS: number, wrapT: number): void {
|
|
1370
|
+
core.methods.setUniformTexture(materialId, uniform, systemId) // record semantic + bind by id
|
|
1371
|
+
const rec = glMaterials.get(materialId)
|
|
1372
|
+
const texture = glTextures.get(systemId)
|
|
1373
|
+
if (!rec || !texture) return
|
|
1374
|
+
const WRAP = [gl.CLAMP_TO_EDGE, gl.REPEAT, gl.MIRRORED_REPEAT]
|
|
1375
|
+
texture.wrapS = WRAP[wrapS] ?? gl.REPEAT
|
|
1376
|
+
texture.wrapT = WRAP[wrapT] ?? gl.REPEAT
|
|
1377
|
+
;(texture as any).needsUpdate = true
|
|
1378
|
+
materialUniform(rec, uniform).value = texture
|
|
1379
|
+
},
|
|
1380
|
+
setMaterial(entityId: number, materialId: number, index: number): void {
|
|
1381
|
+
core.methods.setMaterial(entityId, materialId, index)
|
|
1382
|
+
const rec = glMaterials.get(materialId)
|
|
1383
|
+
const mesh = glEnt(entityId).drawables[index]
|
|
1384
|
+
if (!rec || !mesh) return
|
|
1385
|
+
mesh.program = rec.program
|
|
1386
|
+
wireReceiveShadows(mesh, rec)
|
|
1387
|
+
touchGraph()
|
|
1388
|
+
},
|
|
1389
|
+
getMaterial(entityId: number, index: number): number {
|
|
1390
|
+
const mesh = glEnt(entityId).drawables[index] as any
|
|
1391
|
+
if (!mesh) return 0
|
|
1392
|
+
const id = core.nextMaterialId()
|
|
1393
|
+
glMaterials.set(id, { kind: 'gltf', program: mesh.program })
|
|
1394
|
+
return id
|
|
1395
|
+
},
|
|
1396
|
+
|
|
1397
|
+
// --- culling / shadows (per-drawable GL flags; core also records cast/receive semantics) ---
|
|
1398
|
+
setCulling(entityId: number, culling: boolean): void {
|
|
1399
|
+
for (const d of glEnt(entityId).drawables) d.frustumCulled = culling
|
|
1400
|
+
touchGraph()
|
|
1401
|
+
},
|
|
1402
|
+
setGlbCulling(entityId: number, culling: boolean): void {
|
|
1403
|
+
const walk = (id: number): void => {
|
|
1404
|
+
for (const d of glEnt(id).drawables) d.frustumCulled = culling
|
|
1405
|
+
for (const c of core.ent(id).children) walk(c)
|
|
1406
|
+
}
|
|
1407
|
+
walk(entityId)
|
|
1408
|
+
touchGraph()
|
|
1409
|
+
},
|
|
1410
|
+
// draw order / depth state (Mesh.renderPriority, Material.depthTest / depthWrite): the OGL
|
|
1411
|
+
// renderer sorts by renderOrder inside a pass; the depth flags live on the program
|
|
1412
|
+
setRenderPriority(entityId: number, priority: number): void {
|
|
1413
|
+
for (const d of glEnt(entityId).drawables as any[]) d.renderOrder = priority
|
|
1414
|
+
touchGraph()
|
|
1415
|
+
},
|
|
1416
|
+
setMaterialDepthTest(materialId: number, enable: boolean): void {
|
|
1417
|
+
const rec = glMaterials.get(materialId)
|
|
1418
|
+
if (rec) (rec.program as any).depthTest = enable
|
|
1419
|
+
},
|
|
1420
|
+
setMaterialDepthWrite(materialId: number, enable: boolean): void {
|
|
1421
|
+
const rec = glMaterials.get(materialId)
|
|
1422
|
+
if (rec) (rec.program as any).depthWrite = enable
|
|
1423
|
+
},
|
|
1424
|
+
setMaterialCulling(materialId: number, mode: number): void {
|
|
1425
|
+
const rec = glMaterials.get(materialId)
|
|
1426
|
+
if (rec) (rec.program as any).cullFace = mode === 0 ? false : mode === 1 ? gl.FRONT : gl.BACK
|
|
1427
|
+
},
|
|
1428
|
+
setCastShadows(entityId: number, value: boolean): void {
|
|
1429
|
+
core.methods.setCastShadows(entityId, value)
|
|
1430
|
+
for (const d of glEnt(entityId).drawables as any[]) d._castShadows = value
|
|
1431
|
+
touchGraph()
|
|
1432
|
+
},
|
|
1433
|
+
setReceiveShadows(entityId: number, value: boolean): void {
|
|
1434
|
+
core.methods.setReceiveShadows(entityId, value)
|
|
1435
|
+
for (const d of glEnt(entityId).drawables as any[]) d._receiveShadows = value
|
|
1436
|
+
touchGraph()
|
|
1437
|
+
},
|
|
1438
|
+
setGlbShadows(entityId: number, cast: boolean, receive: boolean): void {
|
|
1439
|
+
core.methods.setGlbShadows(entityId, cast, receive)
|
|
1440
|
+
const walk = (id: number): void => {
|
|
1441
|
+
for (const d of glEnt(id).drawables as any[]) { d._castShadows = cast; d._receiveShadows = receive }
|
|
1442
|
+
for (const c of core.ent(id).children) walk(c)
|
|
1443
|
+
}
|
|
1444
|
+
walk(entityId)
|
|
1445
|
+
touchGraph()
|
|
1446
|
+
},
|
|
1447
|
+
setCastShadowsGlobal(value: boolean): void { castShadowsGlobal = value },
|
|
1448
|
+
setReceiveShadowsGlobal(value: boolean): void { receiveShadowsGlobal = value },
|
|
1449
|
+
|
|
1450
|
+
// --- colliders (CPU raycast picking; web-side, from mesh bounds) ---
|
|
1451
|
+
setColliderFromMesh(entityId: number, meshEntityId: number, form: number): void {
|
|
1452
|
+
if (form !== 0) return
|
|
1453
|
+
const mesh = glEnt(meshEntityId).drawables[0] as any
|
|
1454
|
+
if (!mesh) return
|
|
1455
|
+
const g = mesh.geometry
|
|
1456
|
+
if (!g.bounds) g.computeBoundingBox()
|
|
1457
|
+
if (!g.bounds) return
|
|
1458
|
+
const sid = sceneIdOf(entityId) ?? core.activeSceneId()
|
|
1459
|
+
sceneColliders.get(sid)?.set(entityId, {
|
|
1460
|
+
type: 'box',
|
|
1461
|
+
center: [(g.bounds.min.x + g.bounds.max.x) / 2, (g.bounds.min.y + g.bounds.max.y) / 2, (g.bounds.min.z + g.bounds.max.z) / 2],
|
|
1462
|
+
extent: [(g.bounds.max.x - g.bounds.min.x) / 2, (g.bounds.max.y - g.bounds.min.y) / 2, (g.bounds.max.z - g.bounds.min.z) / 2],
|
|
1463
|
+
})
|
|
1464
|
+
},
|
|
1465
|
+
setColliderBox(entityId: number, cx: number, cy: number, cz: number, sx: number, sy: number, sz: number): void {
|
|
1466
|
+
const sid = sceneIdOf(entityId) ?? core.activeSceneId()
|
|
1467
|
+
sceneColliders.get(sid)?.set(entityId, { type: 'box', center: [cx, cy, cz], extent: [sx / 2, sy / 2, sz / 2] })
|
|
1468
|
+
},
|
|
1469
|
+
setColliderSphere(entityId: number, cx: number, cy: number, cz: number, radius: number): void {
|
|
1470
|
+
const sid = sceneIdOf(entityId) ?? core.activeSceneId()
|
|
1471
|
+
sceneColliders.get(sid)?.set(entityId, { type: 'sphere', center: [cx, cy, cz], radius })
|
|
1472
|
+
},
|
|
1473
|
+
|
|
1474
|
+
// (physics: the core's real simulation — creator-physics' wasm, loaded above with the animation runtime)
|
|
1475
|
+
|
|
1476
|
+
// --- AR (not supported in lite; the simulator overrides these with its own webcam AR methods) ---
|
|
1477
|
+
createARController(): void { throw new Error('createARController is not implemented') },
|
|
1478
|
+
createRootAnchor(): number { throw new Error('createRootAnchor is not implemented') },
|
|
1479
|
+
createAnchor(): number { throw new Error('createAnchor is not implemented') },
|
|
1480
|
+
createARPlane: (): number => 0,
|
|
1481
|
+
updateProjectionMatrixAR(): void {},
|
|
1482
|
+
_setARController(): void {},
|
|
1483
|
+
|
|
1484
|
+
// (particles + noise: the core's creator-particles wasm, drawn through particlesFrame above)
|
|
1485
|
+
|
|
1486
|
+
// --- capture ---
|
|
1487
|
+
/** Draw one frame synchronously — hosts call this immediately before reading the canvas back
|
|
1488
|
+
* (the simulator's "Save image"), since without preserveDrawingBuffer the pixels only survive
|
|
1489
|
+
* inside the task that drew them. captureImage below does the same thing for the app-facing ABI. */
|
|
1490
|
+
_renderNow: () => renderFrame(performance.now()),
|
|
1491
|
+
async captureImage(_sceneId: number, onComplete: (bufferId: number, name: string, size: number) => void, onReject: () => void): Promise<void> {
|
|
1492
|
+
try {
|
|
1493
|
+
renderFrame(performance.now()) // make sure the framebuffer holds a fresh frame
|
|
1494
|
+
const temp = new OffscreenCanvas(canvas.width, canvas.height)
|
|
1495
|
+
const ctx = temp.getContext('2d')!
|
|
1496
|
+
ctx.drawImage(canvas, 0, 0)
|
|
1497
|
+
const blob = await temp.convertToBlob({ type: 'image/png' })
|
|
1498
|
+
const buffer = await blob.arrayBuffer()
|
|
1499
|
+
onComplete(putBuffer(buffer), 'image.png', blob.size)
|
|
1500
|
+
} catch {
|
|
1501
|
+
onReject()
|
|
1502
|
+
}
|
|
1503
|
+
},
|
|
1504
|
+
|
|
1505
|
+
// --- host plumbing (same seam as viewer/createViewer.ts) ---
|
|
1506
|
+
raycastView,
|
|
1507
|
+
_raycastView: raycastView,
|
|
1508
|
+
/** VR-style pick (native raycastPick): colliders first, then renderable AABBs as fallback. */
|
|
1509
|
+
raycastPick(screenX: number, screenY: number): number {
|
|
1510
|
+
const sceneId = core.activeSceneId()
|
|
1511
|
+
const scene = core.scenes.get(sceneId)
|
|
1512
|
+
if (!scene || scene.camera === undefined) return 0
|
|
1513
|
+
const hit = raycastView(screenX, screenY)
|
|
1514
|
+
if (hit !== 0) return hit
|
|
1515
|
+
const { origin, dir } = getViewRay(sceneId, screenX, screenY)
|
|
1516
|
+
return raycastRenderables(sceneId, origin, dir)
|
|
1517
|
+
},
|
|
1518
|
+
computeBoundingBox(entityId: number): Float32Array {
|
|
1519
|
+
// Subtree AABB in the entity's local space (mirrors collider.cpp computeBoundingBox).
|
|
1520
|
+
const out = Float32Array.of(Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity)
|
|
1521
|
+
const corner: number[] = [0, 0, 0]
|
|
1522
|
+
const walk = (id: number, mat: Mat4Like | null): void => {
|
|
1523
|
+
for (const mesh of glEnt(id).drawables as any[]) {
|
|
1524
|
+
const g = mesh.geometry
|
|
1525
|
+
if (!g.bounds) g.computeBoundingBox()
|
|
1526
|
+
if (!g.bounds || !isFinite(g.bounds.min.x)) continue
|
|
1527
|
+
for (let i = 0; i < 8; i++) {
|
|
1528
|
+
const x = i & 1 ? g.bounds.max.x : g.bounds.min.x
|
|
1529
|
+
const y = i & 2 ? g.bounds.max.y : g.bounds.min.y
|
|
1530
|
+
const z = i & 4 ? g.bounds.max.z : g.bounds.min.z
|
|
1531
|
+
if (mat) transformPoint(corner, mat, x, y, z, 1)
|
|
1532
|
+
else { corner[0] = x; corner[1] = y; corner[2] = z }
|
|
1533
|
+
for (let k = 0; k < 3; k++) {
|
|
1534
|
+
if (corner[k] < out[k]) out[k] = corner[k]
|
|
1535
|
+
if (corner[k] > out[k + 3]) out[k + 3] = corner[k]
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
for (const c of core.ent(id).children) {
|
|
1540
|
+
const childLocal = glEnt(c).transform.matrix as unknown as Mat4Like
|
|
1541
|
+
const childMat = mat ? multiply(new Float32Array(16), mat, childLocal) : Float32Array.from(childLocal as ArrayLike<number>)
|
|
1542
|
+
walk(c, childMat)
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
walk(entityId, null)
|
|
1546
|
+
if (!isFinite(out[0])) out.fill(0)
|
|
1547
|
+
return out
|
|
1548
|
+
},
|
|
1549
|
+
stopAllPlayers(): void { removePlayers() },
|
|
1550
|
+
/** The agreement seam: serialize the LIVE core state to the exact `scene3d` JSON `lecodes render`
|
|
1551
|
+
* emits — so a scene running in the browser can be dumped and cross-checked against the pixels
|
|
1552
|
+
* (and the headless output). Colliders/physics/texture names are GL-side here, so they don't
|
|
1553
|
+
* appear; the scene graph, materials, camera and screen-space `frame` do. */
|
|
1554
|
+
_dumpScene3d: (): unknown => serializeScene3d(core),
|
|
1555
|
+
/** EDITOR host API (not part of the bridge ABI / parity surface): wireframe display mode —
|
|
1556
|
+
* 'overlay' draws edge lines over the shaded pass, 'only' replaces it. See the wireframe
|
|
1557
|
+
* section above. The scene editor calls this on its lite viewport; apps never see it. */
|
|
1558
|
+
_setWireframeMode(mode: 'off' | 'overlay' | 'only'): void {
|
|
1559
|
+
wireframeMode = mode === 'overlay' || mode === 'only' ? mode : 'off'
|
|
1560
|
+
// dark lines read on shaded surfaces; light lines read on the bare skybox
|
|
1561
|
+
wireColor.value = wireframeMode === 'only' ? [ 0.62, 0.66, 0.72 ] : [ 0.05, 0.06, 0.08 ]
|
|
1562
|
+
updateWireframe()
|
|
1563
|
+
touchGraph()
|
|
1564
|
+
},
|
|
1565
|
+
/** HOST API (not part of the bridge ABI / parity surface): instanced batching on/off — off
|
|
1566
|
+
* draws every mesh through the per-object path, the reference the batched picture must
|
|
1567
|
+
* match (render-check compares the two). Diagnostics via `_batchStats`. */
|
|
1568
|
+
_setBatching(enabled: boolean): void {
|
|
1569
|
+
for (const b of batchers.values()) b.setEnabled(enabled)
|
|
1570
|
+
batchingEnabled = enabled
|
|
1571
|
+
touchGraph()
|
|
1572
|
+
},
|
|
1573
|
+
_batchStats(): { batches: number, instances: number, visible: number, singles: number } {
|
|
1574
|
+
const root = sceneRoots.get(core.activeSceneId())
|
|
1575
|
+
return root ? batcherFor(root).stats() : { batches: 0, instances: 0, visible: 0, singles: 0 }
|
|
1576
|
+
},
|
|
1577
|
+
/** EDITOR host API (not part of the bridge ABI / parity surface): connect the engine-side
|
|
1578
|
+
* editor layer — grid, selection outline, viewport picking, and the transform gizmo (see
|
|
1579
|
+
* editor.ts). Returns { setGrid, setSelection, setGizmoMode, setGizmoSpace, setColliders, setGizmos };
|
|
1580
|
+
* the handlers
|
|
1581
|
+
* receive picks (entity ids) and completed gizmo drags. The scene editor is the only caller. */
|
|
1582
|
+
_editorConnect(handlers: EditorHandlers) {
|
|
1583
|
+
editorLayer ??= createEditorLayer({
|
|
1584
|
+
gl, renderer, canvas, camera, core, sceneRoots,
|
|
1585
|
+
drawablesOf: (id) => glEnt(id).drawables,
|
|
1586
|
+
collectMeshes, wireGeometryFor, getViewRay, renderScene,
|
|
1587
|
+
})
|
|
1588
|
+
editorLayer.setHandlers(handlers)
|
|
1589
|
+
return editorLayer.api
|
|
1590
|
+
},
|
|
1591
|
+
async createEngine(): Promise<void> {},
|
|
1592
|
+
_fetch,
|
|
1593
|
+
_getPlayer: getPlayer,
|
|
1594
|
+
_createGLTexture(source: HTMLCanvasElement | OffscreenCanvas) {
|
|
1595
|
+
const texture = new Texture(gl, {
|
|
1596
|
+
image: source as any,
|
|
1597
|
+
generateMipmaps: false,
|
|
1598
|
+
flipY: false,
|
|
1599
|
+
premultiplyAlpha: false,
|
|
1600
|
+
})
|
|
1601
|
+
const textureId = nextTexture++
|
|
1602
|
+
glTextures.set(textureId, texture)
|
|
1603
|
+
texture.update()
|
|
1604
|
+
return { gl, glTexture: texture.texture, textureId }
|
|
1605
|
+
},
|
|
1606
|
+
_onResize(width: number, height: number, factor = 1): void {
|
|
1607
|
+
resFactor = factor
|
|
1608
|
+
renderer.dpr = factor // OGL setSize then sizes the backing (× dpr) and pins the CSS box to logical px
|
|
1609
|
+
renderer.setSize(width, height)
|
|
1610
|
+
lastAspect = 0 // force projection refresh
|
|
1611
|
+
},
|
|
1612
|
+
_dispose(): void {
|
|
1613
|
+
editorLayer?.dispose()
|
|
1614
|
+
editorLayer = null
|
|
1615
|
+
for (const b of batchers.values()) b.dispose()
|
|
1616
|
+
batchers.clear()
|
|
1617
|
+
this.closeScene()
|
|
1618
|
+
this.stopAllPlayers()
|
|
1619
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
|
1620
|
+
},
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
bridge satisfies typeof _creator // the whole contract, our signatures checked against it
|
|
1624
|
+
return bridge
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
export type CreatorLite = Awaited<ReturnType<typeof createViewerLite>>
|