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.
Files changed (46) hide show
  1. package/README.md +27 -0
  2. package/package.json +33 -0
  3. package/src/assets/decoders/README.md +13 -0
  4. package/src/assets/decoders/basis_transcoder.js +19 -0
  5. package/src/assets/decoders/basis_transcoder.wasm +0 -0
  6. package/src/assets/decoders/draco_decoder.wasm +0 -0
  7. package/src/assets/decoders/draco_wasm_wrapper.js +117 -0
  8. package/src/assets/neutral_ibl128.ktx +0 -0
  9. package/src/core/animBridge.ts +51 -0
  10. package/src/core/boneNames.ts +22 -0
  11. package/src/core/frame.ts +230 -0
  12. package/src/core/glbStructure.ts +100 -0
  13. package/src/core/headlessBridge.ts +92 -0
  14. package/src/core/mapView.ts +195 -0
  15. package/src/core/math3d.ts +275 -0
  16. package/src/core/particleBridge.ts +154 -0
  17. package/src/core/physicsBridge.ts +305 -0
  18. package/src/core/recording.ts +59 -0
  19. package/src/core/sceneCore.ts +833 -0
  20. package/src/core/serialize.ts +144 -0
  21. package/src/gen/unsupported.gen.ts +212 -0
  22. package/src/index.ts +5 -0
  23. package/src/vendor/creator-anim/dist/creatorAnim.d.ts +123 -0
  24. package/src/vendor/creator-anim/dist/creatorAnim.js +14 -0
  25. package/src/vendor/creator-anim/js/index.ts +656 -0
  26. package/src/vendor/creator-nav/dist/creatorNav.d.ts +56 -0
  27. package/src/vendor/creator-nav/dist/creatorNav.js +14 -0
  28. package/src/vendor/creator-nav/js/index.ts +400 -0
  29. package/src/vendor/creator-particles/dist/creatorParticles.d.ts +43 -0
  30. package/src/vendor/creator-particles/dist/creatorParticles.js +14 -0
  31. package/src/vendor/creator-particles/js/index.ts +137 -0
  32. package/src/vendor/creator-physics/dist/creatorPhysics.d.ts +107 -0
  33. package/src/vendor/creator-physics/dist/creatorPhysics.js +14 -0
  34. package/src/vendor/creator-physics/js/index.ts +515 -0
  35. package/src/vite-env.d.ts +1 -0
  36. package/src/webgl/batching.ts +453 -0
  37. package/src/webgl/createViewerLite.ts +1627 -0
  38. package/src/webgl/customMaterial.ts +228 -0
  39. package/src/webgl/decoders/basis.ts +123 -0
  40. package/src/webgl/decoders/draco.ts +110 -0
  41. package/src/webgl/decoders/index.ts +68 -0
  42. package/src/webgl/decoders/worker.ts +45 -0
  43. package/src/webgl/editor.ts +1297 -0
  44. package/src/webgl/glb.ts +593 -0
  45. package/src/webgl/ibl.ts +137 -0
  46. package/src/webgl/shaders.ts +779 -0
@@ -0,0 +1,228 @@
1
+ // Custom .mat materials for the lite 3D engine.
2
+ //
3
+ // The backend compiles every shader asset with matc for the native platforms; for Web Lite it
4
+ // additionally emits a JSON artifact (sdk/src/compile/liteMaterial.ts is the source of
5
+ // truth for the format — the type below mirrors it): the parsed .mat definition plus the author's
6
+ // fragment body with `materialParams.x` already rewritten to `materialParams_x`. This module wraps
7
+ // that body in the lite engine's own GLSL frame — the MaterialInputs struct, getUV0()-style
8
+ // accessors, and the shared PBR lighting chunk — so a custom material shades consistently with
9
+ // everything else lite renders (approximate vs Filament by design; exact parity is what the full
10
+ // viewer is for).
11
+ //
12
+ // Kept free of WebGL so the assembly logic is bun-testable; createViewerLite owns Program creation.
13
+
14
+ import { LIGHTING_CHUNK } from './shaders'
15
+
16
+ export type LiteMaterialParam = { name: string, type: string, size?: number }
17
+
18
+ /** Mirror of sdk/compile liteMaterial.ts `LiteMaterialArtifact`. */
19
+ export type LiteMaterialArtifact = {
20
+ lecodesLiteMaterial: 1
21
+ shadingModel: 'lit' | 'unlit'
22
+ blending: 'opaque' | 'transparent' | 'fade' | 'add' | 'masked'
23
+ maskThreshold: number
24
+ doubleSided: boolean
25
+ culling: 'back' | 'front' | 'none'
26
+ depthWrite: boolean
27
+ /** Filament's shadow-catcher flag (unlit + transparent, matc-enforced) */
28
+ shadowMultiplier: boolean
29
+ params: LiteMaterialParam[]
30
+ samplers: { name: string }[]
31
+ fragmentCode: string
32
+ writesNormal: boolean
33
+ hasVertexBlock: boolean
34
+ unsupported?: string
35
+ }
36
+
37
+ /** Decode a fetched shader buffer as a lite material artifact; null for anything else
38
+ * (binary filamat packages, error pages, the built-ins' empty local buffers). */
39
+ export const parseLiteMaterial = (buf: ArrayBuffer | null | undefined): LiteMaterialArtifact | null => {
40
+ if (!buf || buf.byteLength < 24 || buf.byteLength > 1_000_000) return null
41
+ if (new Uint8Array(buf, 0, 1)[0] !== 0x7b) return null // '{'
42
+ try {
43
+ const parsed = JSON.parse(new TextDecoder().decode(buf))
44
+ return parsed?.lecodesLiteMaterial === 1 ? parsed as LiteMaterialArtifact : null
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ // ---- GLSL assembly ------------------------------------------------------------------------------
51
+
52
+ const GLSL_TYPE: Record<string, { decl: string, zero: number | number[] | Float32Array }> = {
53
+ float: { decl: 'float', zero: 0 },
54
+ float2: { decl: 'vec2', zero: [0, 0] },
55
+ float3: { decl: 'vec3', zero: [0, 0, 0] },
56
+ float4: { decl: 'vec4', zero: [0, 0, 0, 0] },
57
+ int: { decl: 'int', zero: 0 },
58
+ int2: { decl: 'ivec2', zero: [0, 0] },
59
+ int3: { decl: 'ivec3', zero: [0, 0, 0] },
60
+ int4: { decl: 'ivec4', zero: [0, 0, 0, 0] },
61
+ bool: { decl: 'bool', zero: 0 },
62
+ bool2: { decl: 'bvec2', zero: [0, 0] },
63
+ bool3: { decl: 'bvec3', zero: [0, 0, 0] },
64
+ bool4: { decl: 'bvec4', zero: [0, 0, 0, 0] },
65
+ float3x3: { decl: 'mat3', zero: new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]) },
66
+ float4x4: { decl: 'mat4', zero: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) },
67
+ }
68
+
69
+ // The subset of Filament's material API that authored fragment code commonly touches. The struct
70
+ // carries the full lit field set even for unlit materials — writes to ignored fields are harmless
71
+ // and more forgiving than a compile error.
72
+ const MATERIAL_API_CHUNK = /* glsl */ `
73
+ struct MaterialInputs {
74
+ vec4 baseColor;
75
+ float roughness;
76
+ float metallic;
77
+ float reflectance;
78
+ float ambientOcclusion;
79
+ vec4 emissive;
80
+ vec3 normal;
81
+ float clearCoat;
82
+ float clearCoatRoughness;
83
+ vec4 postLightingColor;
84
+ };
85
+ void prepareMaterial(inout MaterialInputs m) {}
86
+ vec2 getUV0() { return vUv; }
87
+ vec2 getUV1() { return vUv; }
88
+ vec3 getWorldPosition() { return vWorldPos; }
89
+ vec3 getWorldGeometricNormalVector() { return normalize(vNormal); }
90
+ vec3 getWorldViewVector() { return normalize(cameraPosition - vWorldPos); }
91
+ vec4 getUserTime() { return userTime; }
92
+ `
93
+
94
+ // Filament defaults (common_material shading setup).
95
+ const INPUT_DEFAULTS = /* glsl */ `
96
+ MaterialInputs inputs;
97
+ inputs.baseColor = vec4(1.0);
98
+ inputs.roughness = 1.0;
99
+ inputs.metallic = 0.0;
100
+ inputs.reflectance = 0.5;
101
+ inputs.ambientOcclusion = 1.0;
102
+ inputs.emissive = vec4(0.0, 0.0, 0.0, 1.0);
103
+ inputs.normal = vec3(0.0, 0.0, 1.0);
104
+ inputs.clearCoat = 0.0;
105
+ inputs.clearCoatRoughness = 0.0;
106
+ inputs.postLightingColor = vec4(0.0);
107
+ material(inputs);
108
+ `
109
+
110
+ // Tangent-frame from screen-space derivatives (same approach as the glTF normal-map path) — only
111
+ // included when the fragment writes MaterialInputs.normal.
112
+ const TANGENT_NORMAL_CHUNK = /* glsl */ `
113
+ vec3 applyTangentNormal(vec3 nTangent, vec3 ng) {
114
+ vec3 pos_dx = dFdx(vWorldPos);
115
+ vec3 pos_dy = dFdy(vWorldPos);
116
+ vec2 tex_dx = dFdx(vUv);
117
+ vec2 tex_dy = dFdy(vUv);
118
+ float det = tex_dx.x * tex_dy.y - tex_dy.x * tex_dx.y;
119
+ if (abs(det) < 1e-8) return ng;
120
+ vec3 t = normalize(pos_dx * tex_dy.y - pos_dy * tex_dx.y);
121
+ t = normalize(t - ng * dot(ng, t));
122
+ vec3 b = normalize(cross(ng, t)) * sign(det);
123
+ return normalize(mat3(t, b, ng) * nTangent);
124
+ }
125
+ `
126
+
127
+ // Standalone copy of the shared ACES fit for unlit materials (lit gets it from LIGHTING_CHUNK).
128
+ const UNLIT_TONEMAP_CHUNK = /* glsl */ `
129
+ vec3 tonemap(vec3 x) {
130
+ const float A = 2.51; const float B = 0.03; const float C = 2.43; const float D = 0.59; const float E = 0.14;
131
+ vec3 t = clamp((x * (A * x + B)) / (x * (C * x + D) + E), 0.0, 1.0);
132
+ return pow(t, vec3(1.0 / 2.2));
133
+ }
134
+ `
135
+
136
+ export type CustomUniformSpec = { key: string, value: number | number[] | Float32Array | 'texture' }
137
+
138
+ export type CustomProgramSpec = {
139
+ fragment: string
140
+ uniforms: CustomUniformSpec[]
141
+ /** parameter names as the SDK sets them — materialUniform maps these to materialParams_<name> */
142
+ paramNames: Set<string>
143
+ /** the program references the shared lighting/shadow uniforms (lit or shadowMultiplier) */
144
+ needsSceneUniforms: boolean
145
+ }
146
+
147
+ /** Assemble the fragment shader + uniform skeleton for a lite material artifact.
148
+ * Returns null when the artifact declares something this build can't express (defensive —
149
+ * the backend already gates types via `unsupported`). */
150
+ export const buildCustomProgram = (artifact: LiteMaterialArtifact): CustomProgramSpec | null => {
151
+ if (artifact.unsupported) return null
152
+
153
+ const decls: string[] = []
154
+ const uniforms: CustomUniformSpec[] = []
155
+ const paramNames = new Set<string>()
156
+ for (const p of artifact.params) {
157
+ const glsl = GLSL_TYPE[p.type]
158
+ if (!glsl) return null
159
+ decls.push(`uniform ${glsl.decl} materialParams_${p.name}${p.size ? `[${p.size}]` : ''};`)
160
+ const zero = p.size
161
+ ? new Array(p.size * (Array.isArray(glsl.zero) ? glsl.zero.length : glsl.zero instanceof Float32Array ? glsl.zero.length : 1)).fill(0)
162
+ : glsl.zero
163
+ uniforms.push({ key: `materialParams_${p.name}`, value: Array.isArray(zero) ? [...zero] : zero })
164
+ paramNames.add(p.name)
165
+ }
166
+ for (const s of artifact.samplers) {
167
+ decls.push(`uniform sampler2D materialParams_${s.name};`)
168
+ uniforms.push({ key: `materialParams_${s.name}`, value: 'texture' })
169
+ paramNames.add(s.name)
170
+ }
171
+
172
+ const lit = artifact.shadingModel === 'lit'
173
+ const needsSceneUniforms = lit || artifact.shadowMultiplier
174
+ const opaque = artifact.blending === 'opaque' || artifact.blending === 'masked'
175
+ const masked = artifact.blending === 'masked'
176
+ ? `if (inputs.baseColor.a < ${artifact.maskThreshold.toFixed(4)}) discard;` : ''
177
+ const alpha = opaque ? '1.0' : 'inputs.baseColor.a'
178
+
179
+ // Shadow catcher (Filament shadowMultiplier semantics, same output as the built-in
180
+ // shadowCatcherFragment): baseColor scaled by the received shadow amount, premultiplied —
181
+ // fully transparent where nothing shades the surface. No tonemap, matching the built-in.
182
+ const main = artifact.shadowMultiplier ? /* glsl */ `
183
+ void main() {
184
+ ${INPUT_DEFAULTS}
185
+ float shadowAmount = 1.0 - sampleShadowPCF();
186
+ fragColor = vec4(inputs.baseColor.rgb * shadowAmount, inputs.baseColor.a * shadowAmount);
187
+ }
188
+ ` : lit ? /* glsl */ `
189
+ void main() {
190
+ ${INPUT_DEFAULTS}
191
+ ${masked}
192
+ vec3 n = normalize(vNormal);
193
+ vec3 v = normalize(cameraPosition - vWorldPos);
194
+ ${artifact.doubleSided ? 'if (dot(n, v) < 0.0) n = -n;' : ''}
195
+ ${artifact.writesNormal ? 'n = applyTangentNormal(inputs.normal, n);' : ''}
196
+ vec3 diffuseColor = inputs.baseColor.rgb * (1.0 - inputs.metallic);
197
+ vec3 f0 = mix(vec3(0.16 * inputs.reflectance * inputs.reflectance), inputs.baseColor.rgb, inputs.metallic);
198
+ vec3 color = shadePBR(n, v, diffuseColor, f0, inputs.roughness, inputs.ambientOcclusion);
199
+ color += inputs.emissive.rgb * (iblLuminance * exposure);
200
+ color += inputs.postLightingColor.rgb;
201
+ fragColor = vec4(tonemap(color), ${alpha});
202
+ }
203
+ ` : /* glsl */ `
204
+ void main() {
205
+ ${INPUT_DEFAULTS}
206
+ ${masked}
207
+ vec3 color = inputs.baseColor.rgb + inputs.emissive.rgb + inputs.postLightingColor.rgb;
208
+ fragColor = vec4(tonemap(color), ${alpha});
209
+ }
210
+ `
211
+
212
+ const fragment = /* glsl */ `#version 300 es
213
+ precision highp float;
214
+ in vec3 vWorldPos;
215
+ in vec3 vNormal;
216
+ in vec2 vUv;
217
+ uniform vec3 cameraPosition;
218
+ uniform vec4 userTime;
219
+ ${decls.join('\n')}
220
+ ${needsSceneUniforms ? LIGHTING_CHUNK : UNLIT_TONEMAP_CHUNK}
221
+ ${MATERIAL_API_CHUNK}
222
+ ${artifact.writesNormal ? TANGENT_NORMAL_CHUNK : ''}
223
+ out vec4 fragColor;
224
+ ${artifact.fragmentCode}
225
+ ${main}`
226
+
227
+ return { fragment, uniforms, paramNames, needsSceneUniforms }
228
+ }
@@ -0,0 +1,123 @@
1
+ // Lazy KTX2 / Basis Universal texture transcoder for OGL's GLTFLoader (KHR_texture_basisu,
2
+ // `image/ktx2` images). Implements the interface GLTFLoader.setBasisManager expects:
3
+ // `parseTexture(ktx2Data)` → Promise of an image OGL's Texture can upload.
4
+ //
5
+ // Two result shapes:
6
+ // - GPU compressed (astc / bptc / s3tc, picked from the context's extensions): an array of mip
7
+ // levels `[{ data, width, height }]` with `isCompressedTexture` + `internalFormat` — OGL's
8
+ // Texture uploads each level via compressedTexImage2D. Only taken when the KTX2 carries a FULL
9
+ // mip chain (compressed textures can't generate mips on the GPU; a partial chain would sample
10
+ // as an incomplete texture).
11
+ // - Fallback (no compressed format, partial mip chain, cubemaps): level 0 transcoded to RGBA32 as
12
+ // an ImageBitmap on the normal image path — mips regenerate GPU-side.
13
+ //
14
+ // Transcoder build: basis_transcoder.js/.wasm vendored from three.js (the upstream
15
+ // BinomialLLC/basis_universal artifact), vendored in src/assets/decoders.
16
+
17
+ import glueText from '../../assets/decoders/basis_transcoder.js?raw'
18
+ import wasmUrl from '../../assets/decoders/basis_transcoder.wasm?url'
19
+ import { spawnDecoderWorker } from './worker'
20
+
21
+ export type BasisFormat = 'astc' | 'bptc' | 's3tc' | 'rgba32'
22
+
23
+ /** linear + sRGB sibling internal formats per transcode target (sRGB applied by glb.ts markSrgb). */
24
+ export const BASIS_INTERNAL_FORMATS: Record<Exclude<BasisFormat, 'rgba32'>, { linear: number, srgb: number }> = {
25
+ astc: { linear: 0x93b0, srgb: 0x93d0 }, // COMPRESSED_RGBA_ASTC_4x4_KHR / SRGB8_ALPHA8_ASTC_4x4_KHR
26
+ bptc: { linear: 0x8e8c, srgb: 0x8e8d }, // COMPRESSED_RGBA_BPTC_UNORM / COMPRESSED_SRGB_ALPHA_BPTC_UNORM
27
+ s3tc: { linear: 0x83f3, srgb: 0x8c4f }, // COMPRESSED_RGBA_S3TC_DXT5_EXT / COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT
28
+ }
29
+
30
+ // getExtension() also ENABLES the format on the context — required before compressedTexImage2D.
31
+ const pickFormat = (gl: any): { format: BasisFormat, srgbOk: boolean } => {
32
+ if (gl.getExtension('WEBGL_compressed_texture_astc')) return { format: 'astc', srgbOk: true }
33
+ if (gl.getExtension('EXT_texture_compression_bptc')) return { format: 'bptc', srgbOk: true }
34
+ if (gl.getExtension('WEBGL_compressed_texture_s3tc')) {
35
+ return { format: 's3tc', srgbOk: !!gl.getExtension('WEBGL_compressed_texture_s3tc_srgb') }
36
+ }
37
+ return { format: 'rgba32', srgbOk: true }
38
+ }
39
+
40
+ // Runs inside the Blob worker AFTER the basis glue — only globals, no closure over this module.
41
+ const basisWorkerBody = () => {
42
+ const scope = self as any
43
+ let modulePromise: Promise<any>
44
+ // basis transcoder_texture_format values (stable upstream enum)
45
+ const FORMAT_ENUM: Record<string, number> = { astc: 10, bptc: 6, s3tc: 3, rgba32: 13 }
46
+
47
+ const transcode = async (m: any, buffer: ArrayBuffer, format: string) => {
48
+ const ktx2 = new m.KTX2File(new Uint8Array(buffer))
49
+ try {
50
+ if (!ktx2.isValid()) throw new Error('invalid KTX2 file')
51
+ const width = ktx2.getWidth(), height = ktx2.getHeight(), levels = ktx2.getLevels()
52
+ if (!width || !height || !levels) throw new Error('empty KTX2 file')
53
+ if (!ktx2.startTranscoding()) throw new Error('KTX2 transcode init failed')
54
+
55
+ const fullChain = levels >= Math.floor(Math.log2(Math.max(width, height))) + 1
56
+ if (format !== 'rgba32' && fullChain && ktx2.getFaces() === 1) {
57
+ const fmt = FORMAT_ENUM[format]
58
+ const out: any[] = []
59
+ for (let level = 0; level < levels; level++) {
60
+ const info = ktx2.getImageLevelInfo(level, 0, 0)
61
+ const data = new Uint8Array(ktx2.getImageTranscodedSizeInBytes(level, 0, 0, fmt))
62
+ if (!ktx2.transcodeImage(data, level, 0, 0, fmt, 0, -1, -1)) throw new Error('KTX2 level transcode failed')
63
+ out.push({ data, width: info.origWidth, height: info.origHeight })
64
+ }
65
+ return { msg: { levels: out }, transfer: out.map((l) => l.data.buffer) }
66
+ }
67
+
68
+ const data = new Uint8Array(ktx2.getImageTranscodedSizeInBytes(0, 0, 0, FORMAT_ENUM.rgba32))
69
+ if (!ktx2.transcodeImage(data, 0, 0, 0, FORMAT_ENUM.rgba32, 0, -1, -1)) throw new Error('KTX2 transcode failed')
70
+ const bitmap = await createImageBitmap(new ImageData(new Uint8ClampedArray(data.buffer), width, height))
71
+ return { msg: { bitmap }, transfer: [bitmap] }
72
+ } finally {
73
+ ktx2.close()
74
+ ktx2.delete()
75
+ }
76
+ }
77
+
78
+ scope.onmessage = (e: MessageEvent) => {
79
+ const msg = e.data
80
+ if (msg.type === 'init') {
81
+ modulePromise = scope.BASIS({ wasmBinary: msg.wasmBinary }).then((m: any) => { m.initializeBasis(); return m })
82
+ return
83
+ }
84
+ modulePromise.then(async (m) => {
85
+ try {
86
+ const res = await transcode(m, msg.buffer, msg.format)
87
+ scope.postMessage({ id: msg.id, ...res.msg }, res.transfer)
88
+ } catch (err: any) {
89
+ scope.postMessage({ id: msg.id, error: String((err && err.message) || err) })
90
+ }
91
+ })
92
+ }
93
+ }
94
+
95
+ /** `gl` null = no GPU in play: transcode to plain RGBA32 (a picture of the texture rather than a
96
+ * texture — the scene editor's KTX2 thumbnails). */
97
+ export const createBasisManager = async (gl: any | null) => {
98
+ const { format, srgbOk } = gl ? pickFormat(gl) : { format: 'rgba32' as BasisFormat, srgbOk: true }
99
+ const call = await spawnDecoderWorker(glueText, wasmUrl, basisWorkerBody)
100
+ return {
101
+ parseTexture: async (buffer: ArrayBuffer): Promise<any> => {
102
+ const r = await call({ buffer, format })
103
+ if (r.bitmap) {
104
+ // normal (uncompressed) image path: GLTFLoader awaits `ready`, then assigns texture.image
105
+ r.bitmap.ready = Promise.resolve()
106
+ return r.bitmap
107
+ }
108
+ const fmts = BASIS_INTERNAL_FORMATS[format as Exclude<BasisFormat, 'rgba32'>]
109
+ return Object.assign(r.levels as any[], {
110
+ // isBasis routes GLTFLoader.createTexture onto its compressed branch (options.image +
111
+ // internalFormat); the bitmap fallback deliberately leaves it unset to ride the normal
112
+ // image path (ready → texture.image) and get GPU mipmap generation.
113
+ isBasis: true,
114
+ isCompressedTexture: true,
115
+ internalFormat: fmts.linear,
116
+ // markSrgb (glb.ts) swaps to this for baseColor/emissive; 0 = leave linear (ext missing)
117
+ srgbInternalFormat: srgbOk ? fmts.srgb : 0,
118
+ width: r.levels[0].width,
119
+ height: r.levels[0].height,
120
+ })
121
+ },
122
+ }
123
+ }
@@ -0,0 +1,110 @@
1
+ // Lazy Draco geometry decoder for OGL's GLTFLoader (KHR_draco_mesh_compression).
2
+ //
3
+ // The manager implements the exact interface GLTFLoader.setDracoManager expects:
4
+ // `decodeGeometry(bufferViewData, { attributeIds, attributeTypes })` → Promise of
5
+ // `{ attributes: [{ name, array, itemSize }], index?: { array, itemSize } }`.
6
+ // The worker body is adapted from three.js' DRACOWorker against the same google decoder build
7
+ // (draco_wasm_wrapper.js + draco_decoder.wasm, vendored in src/assets/decoders).
8
+
9
+ import glueText from '../../assets/decoders/draco_wasm_wrapper.js?raw'
10
+ import wasmUrl from '../../assets/decoders/draco_decoder.wasm?url'
11
+ import { spawnDecoderWorker } from './worker'
12
+
13
+ // Runs inside the Blob worker AFTER the draco glue — only globals, no closure over this module.
14
+ const dracoWorkerBody = () => {
15
+ const scope = self as any
16
+ let decoderPromise: Promise<any>
17
+
18
+ const decodeAttribute = (draco: any, decoder: any, geom: any, name: string, typeName: string, attribute: any) => {
19
+ const numComponents = attribute.num_components()
20
+ const numValues = geom.num_points() * numComponents
21
+ const TypedArray = scope[typeName]
22
+ const dataType = ({
23
+ Float32Array: draco.DT_FLOAT32, Int8Array: draco.DT_INT8, Int16Array: draco.DT_INT16,
24
+ Int32Array: draco.DT_INT32, Uint8Array: draco.DT_UINT8, Uint16Array: draco.DT_UINT16,
25
+ Uint32Array: draco.DT_UINT32,
26
+ } as Record<string, unknown>)[typeName]
27
+ const byteLength = numValues * TypedArray.BYTES_PER_ELEMENT
28
+ const ptr = draco._malloc(byteLength)
29
+ decoder.GetAttributeDataArrayForAllPoints(geom, attribute, dataType, byteLength, ptr)
30
+ const array = new TypedArray(draco.HEAPF32.buffer, ptr, numValues).slice()
31
+ draco._free(ptr)
32
+ return { name, array, itemSize: numComponents }
33
+ }
34
+
35
+ const decode = (draco: any, buffer: ArrayBuffer, config: any) => {
36
+ const decoder = new draco.Decoder()
37
+ const dbuf = new draco.DecoderBuffer()
38
+ dbuf.Init(new Int8Array(buffer), buffer.byteLength)
39
+ let geom: any
40
+ try {
41
+ const type = decoder.GetEncodedGeometryType(dbuf)
42
+ let status: any
43
+ if (type === draco.TRIANGULAR_MESH) {
44
+ geom = new draco.Mesh()
45
+ status = decoder.DecodeBufferToMesh(dbuf, geom)
46
+ } else {
47
+ geom = new draco.PointCloud()
48
+ status = decoder.DecodeBufferToPointCloud(dbuf, geom)
49
+ }
50
+ if (!status.ok() || geom.ptr === 0) throw new Error('draco decode failed: ' + status.error_msg())
51
+
52
+ const attributes: any[] = []
53
+ for (const name in config.attributeIds) {
54
+ const attribute = decoder.GetAttributeByUniqueId(geom, config.attributeIds[name])
55
+ attributes.push(decodeAttribute(draco, decoder, geom, name, config.attributeTypes[name], attribute))
56
+ }
57
+ const geometry: any = { attributes }
58
+ if (type === draco.TRIANGULAR_MESH) {
59
+ const numIndices = geom.num_faces() * 3
60
+ const byteLength = numIndices * 4
61
+ const ptr = draco._malloc(byteLength)
62
+ decoder.GetTrianglesUInt32Array(geom, byteLength, ptr)
63
+ geometry.index = { array: new Uint32Array(draco.HEAPF32.buffer, ptr, numIndices).slice(), itemSize: 1 }
64
+ draco._free(ptr)
65
+ }
66
+ return geometry
67
+ } finally {
68
+ if (geom) draco.destroy(geom)
69
+ draco.destroy(dbuf)
70
+ draco.destroy(decoder)
71
+ }
72
+ }
73
+
74
+ scope.onmessage = (e: MessageEvent) => {
75
+ const msg = e.data
76
+ if (msg.type === 'init') {
77
+ decoderPromise = new Promise((res) => scope.DracoDecoderModule({ wasmBinary: msg.wasmBinary, onModuleLoaded: res }))
78
+ return
79
+ }
80
+ decoderPromise.then((draco) => {
81
+ try {
82
+ const geometry = decode(draco, msg.buffer, msg.config)
83
+ const transfer = geometry.attributes.map((a: any) => a.array.buffer)
84
+ if (geometry.index) transfer.push(geometry.index.array.buffer)
85
+ scope.postMessage({ id: msg.id, geometry }, transfer)
86
+ } catch (err: any) {
87
+ scope.postMessage({ id: msg.id, error: String((err && err.message) || err) })
88
+ }
89
+ })
90
+ }
91
+ }
92
+
93
+ /** De-instanced shared meshes (dedupeSharedMeshes) decode the same draco bufferView once per copy —
94
+ * cache by the bufferView's data buffer so N references cost ONE decode (GL buffers still
95
+ * duplicate downstream; that's the de-instancing tradeoff). */
96
+ const decodeCache = new WeakMap<ArrayBuffer, Promise<any>>()
97
+
98
+ export const createDracoManager = async () => {
99
+ const call = await spawnDecoderWorker(glueText, wasmUrl, dracoWorkerBody)
100
+ return {
101
+ decodeGeometry: (buffer: ArrayBuffer, config: unknown): Promise<any> => {
102
+ let p = decodeCache.get(buffer)
103
+ if (!p) {
104
+ p = call({ buffer, config }).then((r) => r.geometry)
105
+ decodeCache.set(buffer, p)
106
+ }
107
+ return p
108
+ },
109
+ }
110
+ }
@@ -0,0 +1,68 @@
1
+ // Lazy compressed-GLB decoders (Draco geometry, KTX2/Basis textures) for the lite engine.
2
+ //
3
+ // glb.ts pulls this module in via dynamic import ONLY when a GLB actually declares the extension,
4
+ // so plain models never pay for it. Each decoder is a module-level singleton promise — ONE worker
5
+ // + one wasm fetch shared by every concurrent load, every host, every scene on the page. A failed
6
+ // bring-up (offline, blocked wasm) resets the singleton so a later load can retry, and the failing
7
+ // model degrades to structure entities via loadGlbLite's normal error path.
8
+
9
+ import { GLTFLoader } from 'ogl'
10
+
11
+ let dracoReady: Promise<void> | null = null
12
+ let basisReady: Promise<void> | null = null
13
+ let basisManager: { parseTexture: (buffer: ArrayBuffer) => Promise<any> } | null = null
14
+
15
+ /** 12-byte KTX2 magic («KTX 20» CR LF 0x1A LF). */
16
+ const KTX2_MAGIC = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]
17
+ export const isKtx2Bytes = (b: Uint8Array): boolean => b.length >= 12 && KTX2_MAGIC.every((v, i) => b[i] === v)
18
+
19
+ /** KHR_DF_TRANSFER from the KTX2 DFD: true = sRGB (colour), false = linear (data maps). */
20
+ export const ktx2IsSrgb = (buf: ArrayBuffer): boolean => {
21
+ if (buf.byteLength < 80) return true
22
+ const dv = new DataView(buf)
23
+ const dfd = dv.getUint32(48, true)
24
+ if (dfd + 15 > buf.byteLength) return true
25
+ return dv.getUint8(dfd + 14) !== 1 // 1 = LINEAR, 2 = SRGB (default to sRGB when unspecified)
26
+ }
27
+
28
+ /**
29
+ * The same transcoder GLB loads use, for a standalone KTX2 file (Texture.load). Resolves to either
30
+ * an ImageBitmap (rgba32 fallback) or a compressed level array (isCompressedTexture +
31
+ * internalFormat / srgbInternalFormat) — see createBasisManager.parseTexture.
32
+ */
33
+ export const transcodeKtx2 = async (gl: any, buffer: ArrayBuffer): Promise<any> => {
34
+ await ensureBasisTranscoder(gl)
35
+ return basisManager!.parseTexture(buffer)
36
+ }
37
+
38
+ /**
39
+ * A .ktx2 file as a PICTURE (ImageBitmap), for hosts that want to SHOW the texture rather than
40
+ * upload it — the scene editor's asset thumbnails. Keeps its own transcoder singleton: the
41
+ * engine's manager is bound to whatever compressed format its context picked, and a compressed
42
+ * level array can't be drawn to a canvas.
43
+ */
44
+ let imageBasis: Promise<{ parseTexture: (buffer: ArrayBuffer) => Promise<any> }> | null = null
45
+ export const decodeKtx2Image = async (buffer: ArrayBuffer): Promise<ImageBitmap> => {
46
+ imageBasis ??= import('./basis')
47
+ .then(({ createBasisManager }) => createBasisManager(null))
48
+ .catch((e) => { imageBasis = null; throw e })
49
+ const image = await (await imageBasis).parseTexture(buffer)
50
+ if (!image || (image as any).isCompressedTexture) throw new Error('KTX2 did not transcode to an image')
51
+ return image as ImageBitmap
52
+ }
53
+
54
+ export const ensureDracoDecoder = (): Promise<void> => {
55
+ dracoReady ??= import('./draco')
56
+ .then(({ createDracoManager }) => createDracoManager())
57
+ .then((manager) => { (GLTFLoader as any).setDracoManager(manager) })
58
+ .catch((e) => { dracoReady = null; throw e })
59
+ return dracoReady
60
+ }
61
+
62
+ export const ensureBasisTranscoder = (gl: any): Promise<void> => {
63
+ basisReady ??= import('./basis')
64
+ .then(({ createBasisManager }) => createBasisManager(gl))
65
+ .then((manager) => { basisManager = manager; (GLTFLoader as any).setBasisManager(manager) })
66
+ .catch((e) => { basisReady = null; throw e })
67
+ return basisReady
68
+ }
@@ -0,0 +1,45 @@
1
+ // Shared plumbing for the lazy GLB decoder workers (draco geometry, basis/KTX2 textures).
2
+ //
3
+ // Each decoder is an emscripten classic-script glue (`var DracoDecoderModule = ...` / `var BASIS
4
+ // = ...`, vendored in src/assets/decoders) plus a wasm binary. We build the worker from a Blob:
5
+ // glue text + our stringified worker body — no bundler worker plumbing, works identically in vite
6
+ // dev and library builds. The wasm is fetched on the main thread (a plain `?url` asset) and handed
7
+ // to the worker via the init message, so the whole decoder costs nothing until the first
8
+ // compressed model actually needs it.
9
+
10
+ export type WorkerRpc = (msg: Record<string, unknown>, transfer?: Transferable[]) => Promise<any>
11
+
12
+ export const spawnDecoderWorker = async (
13
+ glueText: string,
14
+ wasmUrl: string,
15
+ body: () => void,
16
+ ): Promise<WorkerRpc> => {
17
+ const wasmBinary = await fetch(wasmUrl).then((r) => {
18
+ if (!r.ok) throw new Error(`decoder wasm fetch failed (${r.status}) — ${wasmUrl}`)
19
+ return r.arrayBuffer()
20
+ })
21
+ const blob = new Blob([glueText, '\n;(', body.toString(), ')();'], { type: 'application/javascript' })
22
+ const worker = new Worker(URL.createObjectURL(blob))
23
+ worker.postMessage({ type: 'init', wasmBinary }, [wasmBinary])
24
+
25
+ let nextId = 1
26
+ const pending = new Map<number, { resolve: (v: any) => void, reject: (e: Error) => void }>()
27
+ worker.onmessage = ({ data }) => {
28
+ const p = pending.get(data.id)
29
+ if (!p) return
30
+ pending.delete(data.id)
31
+ if (data.error) p.reject(new Error(data.error))
32
+ else p.resolve(data)
33
+ }
34
+ worker.onerror = (e) => {
35
+ // a worker-level crash (bad glue, wasm compile failure) fails every in-flight decode
36
+ const err = new Error(`decoder worker error: ${e.message ?? 'unknown'}`)
37
+ for (const p of pending.values()) p.reject(err)
38
+ pending.clear()
39
+ }
40
+ return (msg, transfer = []) => new Promise((resolve, reject) => {
41
+ const id = nextId++
42
+ pending.set(id, { resolve, reject })
43
+ worker.postMessage({ id, ...msg }, transfer)
44
+ })
45
+ }