@vulfram/gltf-loader 0.19.1-alpha

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 ADDED
@@ -0,0 +1,5 @@
1
+ # @vulfram/gltf-loader
2
+
3
+ Loads `.gltf` and `.glb` content into `@vulfram/engine` 3D worlds.
4
+
5
+ Status: initial package scaffold. Runtime loading is not implemented yet.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@vulfram/gltf-loader",
3
+ "version": "0.19.1-alpha",
4
+ "module": "src/index.ts",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "type": "module",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "peerDependencies": {
12
+ "@vulfram/engine": "^0.19.2-alpha"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "^1.3.10",
16
+ "@vulfram/engine": "^0.19.2-alpha"
17
+ },
18
+ "dependencies": {
19
+ "@gltf-transform/core": "^4.3.0",
20
+ "@gltf-transform/extensions": "^4.3.0",
21
+ "@gltf-transform/functions": "^4.3.0"
22
+ }
23
+ }
package/src/binary.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { GLB_MAGIC, GLB_VERSION_2 } from './constants';
2
+ import { GltfLoaderError } from './errors';
3
+ import type { BinaryLike, GltfSourceFormat } from './types';
4
+
5
+ /** Converts supported binary-like inputs to Uint8Array with zero-copy when possible. */
6
+ export function toUint8Array(data: BinaryLike): Uint8Array {
7
+ if (data instanceof Uint8Array) return data;
8
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
9
+ if (ArrayBuffer.isView(data)) {
10
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
11
+ }
12
+ throw new GltfLoaderError('INVALID_INPUT', 'Unsupported binary input type.');
13
+ }
14
+
15
+ /** Detects whether payload appears to be .glb or .gltf JSON. */
16
+ export function detectFormat(bytes: Uint8Array): GltfSourceFormat {
17
+ if (bytes.byteLength >= 12) {
18
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
19
+ const magic = dv.getUint32(0, true);
20
+ const version = dv.getUint32(4, true);
21
+ if (magic === GLB_MAGIC && version === GLB_VERSION_2) {
22
+ return 'glb';
23
+ }
24
+ }
25
+
26
+ for (let i = 0; i < bytes.length; i++) {
27
+ const c = bytes[i];
28
+ if (c === undefined) continue;
29
+ if (c <= 0x20) continue;
30
+ if (c === 0x7b) return 'gltf'; // '{'
31
+ break;
32
+ }
33
+
34
+ throw new GltfLoaderError(
35
+ 'UNSUPPORTED_FORMAT',
36
+ 'Unable to detect glTF format from payload bytes.',
37
+ );
38
+ }
39
+
40
+ /** Decodes data URI payload into raw bytes. */
41
+ export function decodeDataUri(uri: string): Uint8Array {
42
+ if (!uri.startsWith('data:')) {
43
+ throw new GltfLoaderError('UNSUPPORTED_FORMAT', `Invalid data URI: ${uri}`);
44
+ }
45
+ const commaIdx = uri.indexOf(',');
46
+ if (commaIdx < 0) {
47
+ throw new GltfLoaderError('UNSUPPORTED_FORMAT', `Malformed data URI: ${uri}`);
48
+ }
49
+
50
+ const meta = uri.slice('data:'.length, commaIdx);
51
+ const payload = uri.slice(commaIdx + 1);
52
+ const isBase64 = meta.includes(';base64');
53
+
54
+ if (isBase64) {
55
+ return new Uint8Array(Buffer.from(payload, 'base64'));
56
+ }
57
+ return new Uint8Array(Buffer.from(decodeURIComponent(payload), 'utf8'));
58
+ }
59
+
60
+ /** Normalizes external resource map to Uint8Array values. */
61
+ export function normalizeResourceMap(
62
+ resources?: Record<string, BinaryLike>,
63
+ ): Record<string, Uint8Array> {
64
+ if (!resources) return {};
65
+ const out: Record<string, Uint8Array> = {};
66
+ for (const [key, value] of Object.entries(resources)) {
67
+ out[key] = toUint8Array(value);
68
+ }
69
+ return out;
70
+ }
@@ -0,0 +1,4 @@
1
+ export const GLB_MAGIC = 0x46546c67;
2
+ export const GLB_VERSION_2 = 2;
3
+ export const BUFFER_ID_BASE = 100_000_000;
4
+ export const U16_MAX = 0xffff;
package/src/context.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { uploadBuffer } from '@vulfram/engine/core';
2
+ import { BUFFER_ID_BASE } from './constants';
3
+ import type { GltfLoadInput, LoaderContext } from './types';
4
+
5
+ let nextUploadBufferId = BUFFER_ID_BASE;
6
+
7
+ /** Creates mutable loader context for one import operation. */
8
+ export function createContext(input: GltfLoadInput): LoaderContext {
9
+ return {
10
+ worldId: input.worldId,
11
+ warnings: [],
12
+ labelPrefix: input.labelPrefix ?? 'gltf',
13
+ materialMode: input.materialMode ?? 'pbr',
14
+ defaultMaterialId: undefined,
15
+ geometryByPrimitive: new WeakMap(),
16
+ materialBySource: new WeakMap(),
17
+ textureBySource: new WeakMap(),
18
+ textureColorSpaceHint: new WeakMap(),
19
+ uploadedVertexByAccessor: new WeakMap(),
20
+ uploadedIndexByAccessor: new WeakMap(),
21
+ createdGeometryIds: new Set(),
22
+ createdMaterialIds: new Set(),
23
+ createdTextureIds: new Set(),
24
+ counters: {
25
+ entities: 0,
26
+ geometries: 0,
27
+ materials: 0,
28
+ textures: 0,
29
+ },
30
+ };
31
+ }
32
+
33
+ function allocBufferId(): number {
34
+ const id = nextUploadBufferId;
35
+ nextUploadBufferId += 1;
36
+ return id;
37
+ }
38
+
39
+ /** Uploads bytes to engine buffer system and returns allocated buffer id. */
40
+ export function uploadBytes(
41
+ _ctx: LoaderContext,
42
+ usage: 'image-data' | 'vertex-data' | 'index-data',
43
+ bytes: Uint8Array,
44
+ ): number {
45
+ const bufferId = allocBufferId();
46
+ uploadBuffer(bufferId, usage, bytes);
47
+ return bufferId;
48
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { Accessor, TextureInfo } from '@gltf-transform/core';
2
+ import type { GeometryPrimitiveEntry, SamplerMode } from '@vulfram/engine/types';
3
+ import { U16_MAX } from './constants';
4
+ import { GltfLoaderError } from './errors';
5
+
6
+ export function toArray3(v: ArrayLike<number>): [number, number, number] {
7
+ return [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0];
8
+ }
9
+
10
+ export function toArray4(v: ArrayLike<number>, fallbackW = 1): [number, number, number, number] {
11
+ return [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0, v[3] ?? fallbackW];
12
+ }
13
+
14
+ export function semanticToPrimitiveType(
15
+ semantic: string,
16
+ ): GeometryPrimitiveEntry['primitiveType'] | null {
17
+ switch (semantic) {
18
+ case 'POSITION':
19
+ return 'position';
20
+ case 'NORMAL':
21
+ return 'normal';
22
+ case 'TANGENT':
23
+ return 'tangent';
24
+ case 'COLOR_0':
25
+ return 'color';
26
+ case 'TEXCOORD_0':
27
+ case 'TEXCOORD_1':
28
+ return 'u-v';
29
+ case 'JOINTS_0':
30
+ return 'skin-joints';
31
+ case 'WEIGHTS_0':
32
+ return 'skin-weights';
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function samplerFromTextureInfo(
39
+ info: {
40
+ getWrapS(): number;
41
+ getWrapT(): number;
42
+ getMagFilter(): number | null;
43
+ getMinFilter(): number | null;
44
+ } | null,
45
+ ): SamplerMode {
46
+ const WRAP_REPEAT = TextureInfo.WrapMode.REPEAT;
47
+ const WRAP_MIRRORED_REPEAT = TextureInfo.WrapMode.MIRRORED_REPEAT;
48
+ const MAG_LINEAR = TextureInfo.MagFilter.LINEAR;
49
+ const MIN_LINEAR = TextureInfo.MinFilter.LINEAR;
50
+ const MIN_LINEAR_MIPMAP_LINEAR = TextureInfo.MinFilter.LINEAR_MIPMAP_LINEAR;
51
+ const MIN_LINEAR_MIPMAP_NEAREST = TextureInfo.MinFilter.LINEAR_MIPMAP_NEAREST;
52
+
53
+ const wrapS = info?.getWrapS() ?? WRAP_REPEAT;
54
+ const wrapT = info?.getWrapT() ?? WRAP_REPEAT;
55
+ const mag = info?.getMagFilter();
56
+ const min = info?.getMinFilter();
57
+
58
+ const repeat =
59
+ wrapS === WRAP_REPEAT
60
+ || wrapS === WRAP_MIRRORED_REPEAT
61
+ || wrapT === WRAP_REPEAT
62
+ || wrapT === WRAP_MIRRORED_REPEAT;
63
+
64
+ const linear =
65
+ mag === null
66
+ || mag === MAG_LINEAR
67
+ || min === null
68
+ || min === MIN_LINEAR
69
+ || min === MIN_LINEAR_MIPMAP_LINEAR
70
+ || min === MIN_LINEAR_MIPMAP_NEAREST;
71
+
72
+ if (repeat) return linear ? 'linear-repeat' : 'point-repeat';
73
+ return linear ? 'linear-clamp' : 'point-clamp';
74
+ }
75
+
76
+ export function asBytes(array: ArrayBufferView): Uint8Array {
77
+ return new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
78
+ }
79
+
80
+ function toFloatArray(accessor: Accessor, components: number, fillTail = 0): Float32Array {
81
+ const count = accessor.getCount();
82
+ const srcArray = accessor.getArray();
83
+ const elementSize = accessor.getElementSize();
84
+
85
+ if (
86
+ srcArray instanceof Float32Array
87
+ && !accessor.getNormalized()
88
+ && elementSize === components
89
+ ) {
90
+ return srcArray;
91
+ }
92
+
93
+ const out = new Float32Array(count * components);
94
+ const tmp: number[] = [];
95
+ for (let i = 0; i < count; i++) {
96
+ accessor.getElement(i, tmp);
97
+ for (let c = 0; c < components; c++) {
98
+ const v = tmp[c];
99
+ out[i * components + c] = v ?? (c === 3 ? 1 : fillTail);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function toUint16Vec4(accessor: Accessor): Uint16Array {
106
+ const count = accessor.getCount();
107
+ const out = new Uint16Array(count * 4);
108
+ const srcArray = accessor.getArray();
109
+
110
+ if (srcArray instanceof Uint16Array && accessor.getElementSize() === 4) {
111
+ return srcArray;
112
+ }
113
+
114
+ const tmp: number[] = [];
115
+ for (let i = 0; i < count; i++) {
116
+ accessor.getElement(i, tmp);
117
+ out[i * 4 + 0] = Math.max(0, Math.min(U16_MAX, Math.round(tmp[0] ?? 0)));
118
+ out[i * 4 + 1] = Math.max(0, Math.min(U16_MAX, Math.round(tmp[1] ?? 0)));
119
+ out[i * 4 + 2] = Math.max(0, Math.min(U16_MAX, Math.round(tmp[2] ?? 0)));
120
+ out[i * 4 + 3] = Math.max(0, Math.min(U16_MAX, Math.round(tmp[3] ?? 0)));
121
+ }
122
+ return out;
123
+ }
124
+
125
+ function toUint32Indices(accessor: Accessor): Uint32Array {
126
+ const src = accessor.getArray();
127
+ if (!src) {
128
+ throw new GltfLoaderError('PARSE_ERROR', 'Index accessor has no backing array.');
129
+ }
130
+ if (src instanceof Uint32Array) {
131
+ return src;
132
+ }
133
+ const out = new Uint32Array(src.length);
134
+ for (let i = 0; i < src.length; i++) {
135
+ out[i] = src[i] ?? 0;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** Converts accessor data to core-compatible stream bytes by semantic. */
141
+ export function accessorToStreamBytes(accessor: Accessor, semantic: string): Uint8Array {
142
+ if (semantic === 'POSITION' || semantic === 'NORMAL') {
143
+ return asBytes(toFloatArray(accessor, 3));
144
+ }
145
+ if (semantic === 'TANGENT') {
146
+ return asBytes(toFloatArray(accessor, 4));
147
+ }
148
+ if (semantic === 'COLOR_0') {
149
+ return asBytes(toFloatArray(accessor, 4, 1));
150
+ }
151
+ if (semantic === 'TEXCOORD_0' || semantic === 'TEXCOORD_1') {
152
+ return asBytes(toFloatArray(accessor, 2));
153
+ }
154
+ if (semantic === 'JOINTS_0') {
155
+ return asBytes(toUint16Vec4(accessor));
156
+ }
157
+ if (semantic === 'WEIGHTS_0') {
158
+ return asBytes(toFloatArray(accessor, 4));
159
+ }
160
+
161
+ throw new GltfLoaderError('UNSUPPORTED_FEATURE', `Unsupported accessor semantic: ${semantic}`);
162
+ }
163
+
164
+ /** Converts glTF index accessor to u32 index stream bytes required by core. */
165
+ export function accessorToIndexBytes(accessor: Accessor): Uint8Array {
166
+ return asBytes(toUint32Indices(accessor));
167
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { GltfLoaderErrorCode } from './types';
2
+
3
+ /** Loader error with stable error code for callers. */
4
+ export class GltfLoaderError extends Error {
5
+ constructor(public readonly code: GltfLoaderErrorCode, message: string) {
6
+ super(message);
7
+ this.name = 'GltfLoaderError';
8
+ }
9
+ }
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ import {
2
+ dispose3DGeometry,
3
+ dispose3DMaterial,
4
+ dispose3DTexture,
5
+ } from '@vulfram/engine/world3d';
6
+ import { createContext } from './context';
7
+ import { GltfLoaderError } from './errors';
8
+ import { readDocument } from './parse';
9
+ import { buildSceneTemplate, instantiateTemplate } from './scene';
10
+ import type {
11
+ GltfInstantiateOptions,
12
+ GltfLoadInput,
13
+ GltfLoadResult,
14
+ GltfInstance,
15
+ LoadedGltfAsset,
16
+ } from './types';
17
+
18
+ export type {
19
+ BinaryLike,
20
+ GltfInstantiateOptions,
21
+ GltfInstance,
22
+ GltfLoadInput,
23
+ GltfLoadResult,
24
+ GltfLoaderErrorCode,
25
+ LoadedGltfAsset,
26
+ LoadedResourceIds,
27
+ NodeTemplate,
28
+ SceneTemplate,
29
+ GltfSourceFormat,
30
+ RootTransform,
31
+ } from './types';
32
+ export { GltfLoaderError } from './errors';
33
+
34
+ /**
35
+ * Loads glTF/GLB resources and returns a reusable asset template.
36
+ *
37
+ * The returned asset can instantiate entity graphs multiple times, reusing
38
+ * uploaded textures/materials/geometries across instances.
39
+ */
40
+ export async function loadGltfAsset(input: GltfLoadInput): Promise<LoadedGltfAsset> {
41
+ const document = await readDocument(input);
42
+ const ctx = createContext(input);
43
+
44
+ const root = document.getRoot();
45
+ const scene = root.getDefaultScene() ?? root.listScenes()[0] ?? null;
46
+ if (!scene) {
47
+ throw new GltfLoaderError('PARSE_ERROR', 'Document has no scene to import.');
48
+ }
49
+
50
+ const template = buildSceneTemplate(ctx, scene);
51
+ const instances = new Set<GltfInstance>();
52
+ let disposedAll = false;
53
+
54
+ const disposeEntities = () => {
55
+ for (const instance of [...instances]) {
56
+ instance.disposeEntities();
57
+ instances.delete(instance);
58
+ }
59
+ };
60
+
61
+ const disposeAll = () => {
62
+ if (disposedAll) return;
63
+ disposedAll = true;
64
+ disposeEntities();
65
+
66
+ for (const geometryId of ctx.createdGeometryIds) {
67
+ dispose3DGeometry(input.worldId, geometryId);
68
+ }
69
+ for (const materialId of ctx.createdMaterialIds) {
70
+ dispose3DMaterial(input.worldId, materialId);
71
+ }
72
+ for (const textureId of ctx.createdTextureIds) {
73
+ dispose3DTexture(input.worldId, textureId);
74
+ }
75
+ };
76
+
77
+ const instantiate = (options?: GltfInstantiateOptions): GltfInstance => {
78
+ if (disposedAll) {
79
+ throw new GltfLoaderError(
80
+ 'INVALID_INPUT',
81
+ 'Cannot instantiate from a disposed glTF asset. Load it again.',
82
+ );
83
+ }
84
+
85
+ const instance = instantiateTemplate(input.worldId, template, options);
86
+ const originalDispose = instance.disposeEntities;
87
+ instance.disposeEntities = () => {
88
+ originalDispose();
89
+ instances.delete(instance);
90
+ };
91
+ instances.add(instance);
92
+ return instance;
93
+ };
94
+
95
+ return {
96
+ worldId: input.worldId,
97
+ warnings: ctx.warnings,
98
+ template,
99
+ resources: {
100
+ geometries: [...ctx.createdGeometryIds],
101
+ materials: [...ctx.createdMaterialIds],
102
+ textures: [...ctx.createdTextureIds],
103
+ },
104
+ instantiate,
105
+ disposeEntities,
106
+ disposeAll,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Legacy one-shot API.
112
+ *
113
+ * Loads resources and immediately instantiates one entity graph.
114
+ */
115
+ export async function loadGltfScene(input: GltfLoadInput): Promise<GltfLoadResult> {
116
+ const asset = await loadGltfAsset(input);
117
+ const instance = asset.instantiate({
118
+ rootTransform: input.rootTransform,
119
+ });
120
+
121
+ return {
122
+ rootEntityId: instance.rootEntityId,
123
+ entityCount: instance.entityIds.length,
124
+ geometryCount: asset.resources.geometries.length,
125
+ materialCount: asset.resources.materials.length,
126
+ textureCount: asset.resources.textures.length,
127
+ warnings: asset.warnings,
128
+ };
129
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { WebIO, type Document, type GLTF, type JSONDocument } from '@gltf-transform/core';
2
+ import { decodeDataUri, detectFormat, normalizeResourceMap, toUint8Array } from './binary';
3
+ import { GltfLoaderError } from './errors';
4
+ import type { GltfLoadInput } from './types';
5
+
6
+ function parseGltfJsonDocument(
7
+ bytes: Uint8Array,
8
+ resources?: Record<string, import('./types').BinaryLike>,
9
+ ): JSONDocument {
10
+ const decoder = new TextDecoder('utf-8');
11
+ let json: GLTF.IGLTF;
12
+ try {
13
+ json = JSON.parse(decoder.decode(bytes)) as GLTF.IGLTF;
14
+ } catch (error) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ throw new GltfLoaderError('PARSE_ERROR', `Failed parsing glTF JSON: ${message}`);
17
+ }
18
+
19
+ const resourceMap = normalizeResourceMap(resources);
20
+
21
+ const registerUri = (uri: string) => {
22
+ if (resourceMap[uri]) return;
23
+ if (uri.startsWith('data:')) {
24
+ resourceMap[uri] = decodeDataUri(uri);
25
+ return;
26
+ }
27
+ throw new GltfLoaderError(
28
+ 'MISSING_RESOURCE',
29
+ `External resource not provided for URI "${uri}". Provide it in input.resources.`,
30
+ );
31
+ };
32
+
33
+ for (const buffer of json.buffers ?? []) {
34
+ if (buffer.uri) registerUri(buffer.uri);
35
+ }
36
+ for (const image of json.images ?? []) {
37
+ if (image.uri) registerUri(image.uri);
38
+ }
39
+
40
+ return {
41
+ json,
42
+ resources: resourceMap as unknown as { [s: string]: Uint8Array<ArrayBuffer> },
43
+ };
44
+ }
45
+
46
+ /** Reads input payload into a glTF Transform document. */
47
+ export async function readDocument(input: GltfLoadInput): Promise<Document> {
48
+ const io = new WebIO();
49
+ const bytes = toUint8Array(input.data);
50
+ const format = input.format ?? detectFormat(bytes);
51
+
52
+ try {
53
+ if (format === 'glb') {
54
+ return await io.readBinary(bytes);
55
+ }
56
+ const jsonDoc = parseGltfJsonDocument(bytes, input.resources);
57
+ return await io.readJSON(jsonDoc);
58
+ } catch (error) {
59
+ if (error instanceof GltfLoaderError) throw error;
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ throw new GltfLoaderError('PARSE_ERROR', `Failed to read glTF document: ${message}`);
62
+ }
63
+ }
@@ -0,0 +1,295 @@
1
+ import { Primitive, type Accessor, type Material, type Texture } from '@gltf-transform/core';
2
+ import {
3
+ create3DGeometry,
4
+ create3DMaterial,
5
+ create3DTexture,
6
+ type GeometryId,
7
+ type MaterialId,
8
+ type TextureId,
9
+ } from '@vulfram/engine/world3d';
10
+ import type { GeometryPrimitiveEntry } from '@vulfram/engine/types';
11
+ import { uploadBytes } from './context';
12
+ import {
13
+ accessorToIndexBytes,
14
+ accessorToStreamBytes,
15
+ samplerFromTextureInfo,
16
+ semanticToPrimitiveType,
17
+ toArray4,
18
+ } from './convert';
19
+ import type { LoaderContext } from './types';
20
+
21
+ function uploadVertexAccessor(
22
+ ctx: LoaderContext,
23
+ accessor: Accessor,
24
+ semantic: string,
25
+ ): number {
26
+ const cached = ctx.uploadedVertexByAccessor.get(accessor);
27
+ if (cached !== undefined) return cached;
28
+
29
+ const bytes = accessorToStreamBytes(accessor, semantic);
30
+ const bufferId = uploadBytes(ctx, 'vertex-data', bytes);
31
+ ctx.uploadedVertexByAccessor.set(accessor, bufferId);
32
+ return bufferId;
33
+ }
34
+
35
+ function uploadIndexAccessor(ctx: LoaderContext, accessor: Accessor): number {
36
+ const cached = ctx.uploadedIndexByAccessor.get(accessor);
37
+ if (cached !== undefined) return cached;
38
+
39
+ const bytes = accessorToIndexBytes(accessor);
40
+ const bufferId = uploadBytes(ctx, 'index-data', bytes);
41
+ ctx.uploadedIndexByAccessor.set(accessor, bufferId);
42
+ return bufferId;
43
+ }
44
+
45
+ /** Ensures a core texture exists for source texture. */
46
+ export function ensureTexture(
47
+ ctx: LoaderContext,
48
+ texture: Texture,
49
+ srgb: boolean,
50
+ ): TextureId | null {
51
+ const existing = ctx.textureBySource.get(texture);
52
+ if (existing !== undefined) {
53
+ const previous = ctx.textureColorSpaceHint.get(texture);
54
+ if (previous !== undefined && previous !== srgb) {
55
+ ctx.warnings.push(
56
+ `Texture "${texture.getName() || 'unnamed'}" reused with mixed color-space expectations; keeping first value (srgb=${previous}).`,
57
+ );
58
+ }
59
+ return existing;
60
+ }
61
+
62
+ const image = texture.getImage();
63
+ if (!image) {
64
+ ctx.warnings.push(
65
+ `Texture "${texture.getName() || 'unnamed'}" has no image bytes and was skipped.`,
66
+ );
67
+ return null;
68
+ }
69
+
70
+ const bufferId = uploadBytes(ctx, 'image-data', image);
71
+ const textureId = create3DTexture(ctx.worldId, {
72
+ label: `${ctx.labelPrefix}:tex:${texture.getName() || 'unnamed'}`,
73
+ source: { type: 'buffer', bufferId },
74
+ srgb,
75
+ mode: 'standalone',
76
+ });
77
+
78
+ ctx.textureBySource.set(texture, textureId);
79
+ ctx.textureColorSpaceHint.set(texture, srgb);
80
+ ctx.createdTextureIds.add(textureId);
81
+ ctx.counters.textures += 1;
82
+ return textureId;
83
+ }
84
+
85
+ function alphaModeToSurfaceType(alphaMode: string): 'opaque' | 'transparent' | 'masked' {
86
+ if (alphaMode === 'BLEND') return 'transparent';
87
+ if (alphaMode === 'MASK') return 'masked';
88
+ return 'opaque';
89
+ }
90
+
91
+ function ensureDefaultMaterial(ctx: LoaderContext): MaterialId {
92
+ if (ctx.defaultMaterialId !== undefined) return ctx.defaultMaterialId;
93
+
94
+ if (ctx.materialMode === 'standard') {
95
+ const id = create3DMaterial(ctx.worldId, {
96
+ kind: 'standard',
97
+ label: `${ctx.labelPrefix}:mat:default`,
98
+ options: {
99
+ type: 'standard',
100
+ content: {
101
+ baseColor: [1, 1, 1, 1],
102
+ surfaceType: 'opaque',
103
+ flags: 0,
104
+ },
105
+ },
106
+ });
107
+
108
+ ctx.defaultMaterialId = id;
109
+ ctx.createdMaterialIds.add(id);
110
+ ctx.counters.materials += 1;
111
+ return id;
112
+ }
113
+
114
+ const id = create3DMaterial(ctx.worldId, {
115
+ kind: 'pbr',
116
+ label: `${ctx.labelPrefix}:mat:default`,
117
+ options: {
118
+ type: 'pbr',
119
+ content: {
120
+ baseColor: [1, 1, 1, 1],
121
+ surfaceType: 'opaque',
122
+ emissiveColor: [0, 0, 0, 1],
123
+ metallic: 0,
124
+ roughness: 1,
125
+ ao: 1,
126
+ normalScale: 1,
127
+ flags: 0,
128
+ },
129
+ },
130
+ });
131
+
132
+ ctx.defaultMaterialId = id;
133
+ ctx.createdMaterialIds.add(id);
134
+ ctx.counters.materials += 1;
135
+ return id;
136
+ }
137
+
138
+ /** Ensures a core material exists for source material. */
139
+ export function ensureMaterial(ctx: LoaderContext, material: Material | null): MaterialId {
140
+ if (!material) return ensureDefaultMaterial(ctx);
141
+
142
+ const existing = ctx.materialBySource.get(material);
143
+ if (existing !== undefined) return existing;
144
+
145
+ const baseTexture = material.getBaseColorTexture();
146
+ const normalTexture = material.getNormalTexture();
147
+ const mrTexture = material.getMetallicRoughnessTexture();
148
+ const emissiveTexture = material.getEmissiveTexture();
149
+ const aoTexture = material.getOcclusionTexture();
150
+
151
+ const baseTexId = baseTexture ? ensureTexture(ctx, baseTexture, true) : null;
152
+ const normalTexId = normalTexture ? ensureTexture(ctx, normalTexture, false) : null;
153
+ const mrTexId = mrTexture ? ensureTexture(ctx, mrTexture, false) : null;
154
+ const emissiveTexId = emissiveTexture ? ensureTexture(ctx, emissiveTexture, true) : null;
155
+ const aoTexId = aoTexture ? ensureTexture(ctx, aoTexture, false) : null;
156
+
157
+ const baseSampler = samplerFromTextureInfo(material.getBaseColorTextureInfo());
158
+ const normalSampler = samplerFromTextureInfo(material.getNormalTextureInfo());
159
+ const mrSampler = samplerFromTextureInfo(material.getMetallicRoughnessTextureInfo());
160
+ const emissiveSampler = samplerFromTextureInfo(material.getEmissiveTextureInfo());
161
+ const aoSampler = samplerFromTextureInfo(material.getOcclusionTextureInfo());
162
+ const emissiveFactor = material.getEmissiveFactor();
163
+
164
+ const materialId = create3DMaterial(ctx.worldId, {
165
+ kind: ctx.materialMode === 'pbr' ? 'pbr' : 'standard',
166
+ label: `${ctx.labelPrefix}:mat:${material.getName() || 'unnamed'}`,
167
+ options: {
168
+ ...(ctx.materialMode === 'pbr'
169
+ ? {
170
+ type: 'pbr' as const,
171
+ content: {
172
+ baseColor: toArray4(material.getBaseColorFactor(), 1),
173
+ surfaceType: alphaModeToSurfaceType(material.getAlphaMode()),
174
+ emissiveColor: [
175
+ emissiveFactor[0] ?? 0,
176
+ emissiveFactor[1] ?? 0,
177
+ emissiveFactor[2] ?? 0,
178
+ 1,
179
+ ] as [number, number, number, number],
180
+ metallic: material.getMetallicFactor(),
181
+ roughness: material.getRoughnessFactor(),
182
+ ao: material.getOcclusionStrength(),
183
+ normalScale: material.getNormalScale(),
184
+ baseTexId,
185
+ baseSampler,
186
+ normalTexId,
187
+ normalSampler,
188
+ metallicRoughnessTexId: mrTexId,
189
+ metallicRoughnessSampler: mrSampler,
190
+ emissiveTexId,
191
+ emissiveSampler,
192
+ aoTexId,
193
+ aoSampler,
194
+ flags: material.getDoubleSided() ? 1 : 0,
195
+ },
196
+ }
197
+ : {
198
+ type: 'standard' as const,
199
+ content: {
200
+ baseColor: toArray4(material.getBaseColorFactor(), 1),
201
+ surfaceType: alphaModeToSurfaceType(material.getAlphaMode()),
202
+ specColor: [
203
+ material.getMetallicFactor(),
204
+ material.getRoughnessFactor(),
205
+ material.getOcclusionStrength(),
206
+ 1,
207
+ ] as [number, number, number, number],
208
+ specPower: 32,
209
+ baseTexId,
210
+ baseSampler,
211
+ normalTexId,
212
+ normalSampler,
213
+ specTexId: mrTexId,
214
+ specSampler: mrSampler,
215
+ flags: material.getDoubleSided() ? 1 : 0,
216
+ },
217
+ }),
218
+ },
219
+ });
220
+
221
+ ctx.materialBySource.set(material, materialId);
222
+ ctx.createdMaterialIds.add(materialId);
223
+ ctx.counters.materials += 1;
224
+ return materialId;
225
+ }
226
+
227
+ /** Ensures a core geometry exists for source primitive. */
228
+ export function ensurePrimitiveGeometry(
229
+ ctx: LoaderContext,
230
+ primitive: Primitive,
231
+ ): GeometryId | null {
232
+ const existing = ctx.geometryByPrimitive.get(primitive);
233
+ if (existing !== undefined) return existing;
234
+
235
+ if (primitive.getMode() !== Primitive.Mode.TRIANGLES) {
236
+ ctx.warnings.push(
237
+ `Primitive mode ${primitive.getMode()} is not supported in v1 loader (triangles only). Primitive skipped.`,
238
+ );
239
+ return null;
240
+ }
241
+
242
+ const entries: GeometryPrimitiveEntry[] = [];
243
+ const indexAccessor = primitive.getIndices();
244
+ if (indexAccessor) {
245
+ entries.push({
246
+ primitiveType: 'index',
247
+ bufferId: uploadIndexAccessor(ctx, indexAccessor),
248
+ });
249
+ }
250
+
251
+ const orderedSemantics = [
252
+ 'POSITION',
253
+ 'NORMAL',
254
+ 'TANGENT',
255
+ 'COLOR_0',
256
+ 'TEXCOORD_0',
257
+ 'TEXCOORD_1',
258
+ ] as const;
259
+
260
+ let hasPosition = false;
261
+
262
+ for (const semantic of orderedSemantics) {
263
+ const accessor = primitive.getAttribute(semantic);
264
+ if (!accessor) continue;
265
+
266
+ const primitiveType = semanticToPrimitiveType(semantic);
267
+ if (!primitiveType) {
268
+ ctx.warnings.push(`Unsupported semantic "${semantic}" ignored.`);
269
+ continue;
270
+ }
271
+
272
+ if (semantic === 'POSITION') hasPosition = true;
273
+
274
+ entries.push({
275
+ primitiveType,
276
+ bufferId: uploadVertexAccessor(ctx, accessor, semantic),
277
+ });
278
+ }
279
+
280
+ if (!hasPosition) {
281
+ ctx.warnings.push('Primitive without POSITION stream skipped.');
282
+ return null;
283
+ }
284
+
285
+ const geometryId = create3DGeometry(ctx.worldId, {
286
+ type: 'custom',
287
+ label: `${ctx.labelPrefix}:geo:${primitive.getName() || 'unnamed'}`,
288
+ entries,
289
+ });
290
+
291
+ ctx.geometryByPrimitive.set(primitive, geometryId);
292
+ ctx.createdGeometryIds.add(geometryId);
293
+ ctx.counters.geometries += 1;
294
+ return geometryId;
295
+ }
package/src/scene.ts ADDED
@@ -0,0 +1,164 @@
1
+ import type { Node, Scene } from '@gltf-transform/core';
2
+ import {
3
+ create3DEntity,
4
+ create3DModel,
5
+ remove3DEntity,
6
+ set3DParent,
7
+ update3DTransform,
8
+ type EntityId,
9
+ type World3DId,
10
+ } from '@vulfram/engine/world3d';
11
+ import { toArray3, toArray4 } from './convert';
12
+ import { ensureMaterial, ensurePrimitiveGeometry } from './resources';
13
+ import type {
14
+ GltfInstance,
15
+ GltfInstantiateOptions,
16
+ LoaderContext,
17
+ SceneTemplate,
18
+ } from './types';
19
+
20
+ function createNodeTemplate(ctx: LoaderContext, node: Node, nodes: SceneTemplate['nodes']): number {
21
+ const mesh = node.getMesh();
22
+ const primitives: SceneTemplate['nodes'][number]['primitives'] = [];
23
+
24
+ if (mesh) {
25
+ for (const primitive of mesh.listPrimitives()) {
26
+ const geometryId = ensurePrimitiveGeometry(ctx, primitive);
27
+ if (!geometryId) continue;
28
+ const materialId = ensureMaterial(ctx, primitive.getMaterial());
29
+ primitives.push({ geometryId, materialId });
30
+ }
31
+ }
32
+
33
+ const nodeIndex = nodes.length;
34
+ nodes.push({
35
+ name: node.getName() || undefined,
36
+ translation: toArray3(node.getTranslation()),
37
+ rotation: toArray4(node.getRotation(), 1),
38
+ scale: toArray3(node.getScale()),
39
+ children: [],
40
+ primitives,
41
+ });
42
+
43
+ for (const child of node.listChildren()) {
44
+ const childIndex = createNodeTemplate(ctx, child, nodes);
45
+ nodes[nodeIndex]!.children.push(childIndex);
46
+ }
47
+
48
+ return nodeIndex;
49
+ }
50
+
51
+ /** Builds an immutable scene template and uploads all required resources. */
52
+ export function buildSceneTemplate(ctx: LoaderContext, scene: Scene): SceneTemplate {
53
+ const nodes: SceneTemplate['nodes'] = [];
54
+ const roots: number[] = [];
55
+
56
+ for (const node of scene.listChildren()) {
57
+ roots.push(createNodeTemplate(ctx, node, nodes));
58
+ }
59
+
60
+ return { roots, nodes };
61
+ }
62
+
63
+ function setInstanceRootTransform(
64
+ worldId: World3DId,
65
+ rootEntityId: EntityId,
66
+ options: GltfInstantiateOptions | undefined,
67
+ ): void {
68
+ const rootTransform = options?.rootTransform;
69
+ update3DTransform(worldId, rootEntityId, {
70
+ position: rootTransform?.position ?? [0, 0, 0],
71
+ rotation: rootTransform?.rotation ?? [0, 0, 0, 1],
72
+ scale: rootTransform?.scale ?? [1, 1, 1],
73
+ });
74
+ }
75
+
76
+ /** Instantiates entities/models/parents from a previously built scene template. */
77
+ export function instantiateTemplate(
78
+ worldId: World3DId,
79
+ template: SceneTemplate,
80
+ options?: GltfInstantiateOptions,
81
+ ): GltfInstance {
82
+ const entityIds: EntityId[] = [];
83
+ let disposed = false;
84
+
85
+ const rootEntityId = create3DEntity(worldId);
86
+ entityIds.push(rootEntityId);
87
+ setInstanceRootTransform(worldId, rootEntityId, options);
88
+
89
+ const nodeEntityIds: EntityId[] = new Array(template.nodes.length);
90
+
91
+ for (let i = 0; i < template.nodes.length; i++) {
92
+ const node = template.nodes[i];
93
+ if (!node) continue;
94
+
95
+ const nodeEntityId = create3DEntity(worldId);
96
+ nodeEntityIds[i] = nodeEntityId;
97
+ entityIds.push(nodeEntityId);
98
+
99
+ update3DTransform(worldId, nodeEntityId, {
100
+ position: node.translation,
101
+ rotation: node.rotation,
102
+ scale: node.scale,
103
+ });
104
+ }
105
+
106
+ for (const rootIndex of template.roots) {
107
+ const rootNodeEntity = nodeEntityIds[rootIndex];
108
+ if (rootNodeEntity === undefined) continue;
109
+ set3DParent(worldId, rootNodeEntity, rootEntityId);
110
+ }
111
+
112
+ for (let i = 0; i < template.nodes.length; i++) {
113
+ const node = template.nodes[i];
114
+ const parentEntity = nodeEntityIds[i];
115
+ if (!node || parentEntity === undefined) continue;
116
+
117
+ for (const childIndex of node.children) {
118
+ const childEntity = nodeEntityIds[childIndex];
119
+ if (childEntity === undefined) continue;
120
+ set3DParent(worldId, childEntity, parentEntity);
121
+ }
122
+
123
+ if (node.primitives.length === 0) continue;
124
+
125
+ if (node.primitives.length === 1) {
126
+ const primitive = node.primitives[0]!;
127
+ create3DModel(worldId, parentEntity, {
128
+ geometryId: primitive.geometryId,
129
+ materialId: primitive.materialId,
130
+ });
131
+ continue;
132
+ }
133
+
134
+ for (const primitive of node.primitives) {
135
+ const modelEntity = create3DEntity(worldId);
136
+ entityIds.push(modelEntity);
137
+ update3DTransform(worldId, modelEntity, {
138
+ position: [0, 0, 0],
139
+ rotation: [0, 0, 0, 1],
140
+ scale: [1, 1, 1],
141
+ });
142
+ set3DParent(worldId, modelEntity, parentEntity);
143
+ create3DModel(worldId, modelEntity, {
144
+ geometryId: primitive.geometryId,
145
+ materialId: primitive.materialId,
146
+ });
147
+ }
148
+ }
149
+
150
+ return {
151
+ rootEntityId,
152
+ entityIds,
153
+ disposeEntities() {
154
+ if (disposed) return;
155
+ disposed = true;
156
+ for (let i = entityIds.length - 1; i >= 0; i--) {
157
+ const entityId = entityIds[i];
158
+ if (entityId !== undefined) {
159
+ remove3DEntity(worldId, entityId);
160
+ }
161
+ }
162
+ },
163
+ };
164
+ }
package/src/types.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type { Accessor, Material, Node, Primitive, Texture } from '@gltf-transform/core';
2
+ import type {
3
+ EntityId,
4
+ GeometryId,
5
+ MaterialId,
6
+ TextureId,
7
+ World3DId,
8
+ } from '@vulfram/engine/world3d';
9
+
10
+ /** Supported source formats for glTF scene loading. */
11
+ export type GltfSourceFormat = 'gltf' | 'glb';
12
+
13
+ /** Binary-like payload accepted by the loader. */
14
+ export type BinaryLike = Uint8Array | ArrayBuffer | ArrayBufferView;
15
+
16
+ /** Optional root transform applied to the imported scene root entity. */
17
+ export type RootTransform = {
18
+ position?: [number, number, number];
19
+ rotation?: [number, number, number, number];
20
+ scale?: [number, number, number];
21
+ };
22
+
23
+ /** Instantiation options for a loaded glTF asset template. */
24
+ export interface GltfInstantiateOptions {
25
+ rootTransform?: RootTransform;
26
+ }
27
+
28
+ /** One instantiated scene graph from a loaded glTF asset. */
29
+ export interface GltfInstance {
30
+ rootEntityId: EntityId;
31
+ entityIds: EntityId[];
32
+ disposeEntities(): void;
33
+ }
34
+
35
+ /** Static template node with local transform and mesh primitive bindings. */
36
+ export interface NodeTemplate {
37
+ name?: string;
38
+ translation: [number, number, number];
39
+ rotation: [number, number, number, number];
40
+ scale: [number, number, number];
41
+ children: number[];
42
+ primitives: Array<{
43
+ geometryId: GeometryId;
44
+ materialId: MaterialId;
45
+ }>;
46
+ }
47
+
48
+ /** Immutable scene template produced from glTF document parsing. */
49
+ export interface SceneTemplate {
50
+ roots: number[];
51
+ nodes: NodeTemplate[];
52
+ }
53
+
54
+ /** Resource IDs allocated for one loaded glTF asset. */
55
+ export interface LoadedResourceIds {
56
+ geometries: GeometryId[];
57
+ materials: MaterialId[];
58
+ textures: TextureId[];
59
+ }
60
+
61
+ /** Loaded glTF asset with reusable resources and instantiation API. */
62
+ export interface LoadedGltfAsset {
63
+ worldId: World3DId;
64
+ warnings: string[];
65
+ template: SceneTemplate;
66
+ resources: LoadedResourceIds;
67
+ instantiate(options?: GltfInstantiateOptions): GltfInstance;
68
+ disposeEntities(): void;
69
+ disposeAll(): void;
70
+ }
71
+
72
+ /** Input descriptor for glTF/GLB loading. */
73
+ export interface GltfLoadInput {
74
+ worldId: World3DId;
75
+ data: BinaryLike;
76
+ format?: GltfSourceFormat;
77
+ materialMode?: 'pbr' | 'standard';
78
+ resources?: Record<string, BinaryLike>;
79
+ rootTransform?: RootTransform;
80
+ labelPrefix?: string;
81
+ }
82
+
83
+ /** Result summary for a loaded glTF scene. */
84
+ export interface GltfLoadResult {
85
+ rootEntityId: EntityId;
86
+ entityCount: number;
87
+ geometryCount: number;
88
+ materialCount: number;
89
+ textureCount: number;
90
+ warnings: string[];
91
+ }
92
+
93
+ /** Stable loader error codes for caller handling. */
94
+ export type GltfLoaderErrorCode =
95
+ | 'INVALID_INPUT'
96
+ | 'UNSUPPORTED_FORMAT'
97
+ | 'PARSE_ERROR'
98
+ | 'UNSUPPORTED_FEATURE'
99
+ | 'MISSING_RESOURCE';
100
+
101
+ export type LoaderCounters = {
102
+ entities: number;
103
+ geometries: number;
104
+ materials: number;
105
+ textures: number;
106
+ };
107
+
108
+ export type LoaderContext = {
109
+ worldId: World3DId;
110
+ warnings: string[];
111
+ labelPrefix: string;
112
+ materialMode: 'pbr' | 'standard';
113
+ defaultMaterialId?: MaterialId;
114
+ geometryByPrimitive: WeakMap<Primitive, GeometryId>;
115
+ materialBySource: WeakMap<Material, MaterialId>;
116
+ textureBySource: WeakMap<Texture, TextureId>;
117
+ textureColorSpaceHint: WeakMap<Texture, boolean>;
118
+ uploadedVertexByAccessor: WeakMap<Accessor, number>;
119
+ uploadedIndexByAccessor: WeakMap<Accessor, number>;
120
+ createdGeometryIds: Set<GeometryId>;
121
+ createdMaterialIds: Set<MaterialId>;
122
+ createdTextureIds: Set<TextureId>;
123
+ counters: LoaderCounters;
124
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "allowJs": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "verbatimModuleSyntax": true,
11
+ "noEmit": true,
12
+ "strict": true,
13
+ "skipLibCheck": true,
14
+ "noFallthroughCasesInSwitch": true,
15
+ "noUncheckedIndexedAccess": true,
16
+ "noImplicitOverride": true
17
+ },
18
+ "include": ["src"]
19
+ }