Переглянути джерело

JSON v5: Centralize buffers and use UUID-keyed collections

- Bump serialization version to 5
- Extract buffer data to top-level `buffers` object to avoid duplication
- Change collections (geometries, materials, textures, etc.) from arrays to UUID-keyed objects
- Remove redundant uuid property from items (uuid is now the key)
- Use explicit type strings instead of boolean flags:
  - `isInstancedBufferAttribute` → `type: 'InstancedBufferAttribute'`
  - `isInterleavedBufferAttribute` → `type: 'InterleavedBufferAttribute'`
  - `isInstancedInterleavedBuffer` → `type: 'InstancedInterleavedBuffer'`
- Add `arrayType` property to preserve typed array constructor name
- ObjectLoader: Add `_distributeBuffers()` to merge buffers into geometries
- ObjectLoader: Add `_convertLegacyCollections()` for v4 compatibility
- ObjectLoader: Parse methods now iterate over objects natively
- BufferGeometryLoader: Support both v4 and v5 attribute formats

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Mr.doob 2 місяців тому
батько
коміт
d51be1a442

+ 1 - 1
src/core/BufferGeometry.js

@@ -1213,7 +1213,7 @@ class BufferGeometry extends EventDispatcher {
 
 		const data = {
 			metadata: {
-				version: 4.7,
+				version: 5,
 				type: 'BufferGeometry',
 				generator: 'BufferGeometry.toJSON'
 			}

+ 2 - 2
src/core/InstancedBufferAttribute.js

@@ -55,10 +55,10 @@ class InstancedBufferAttribute extends BufferAttribute {
 
 		const data = super.toJSON();
 
+		data.type = 'InstancedBufferAttribute';
+		data.arrayType = data.type;
 		data.meshPerAttribute = this.meshPerAttribute;
 
-		data.isInstancedBufferAttribute = true;
-
 		return data;
 
 	}

+ 1 - 1
src/core/InstancedInterleavedBuffer.js

@@ -62,7 +62,7 @@ class InstancedInterleavedBuffer extends InterleavedBuffer {
 
 		const json = super.toJSON( data );
 
-		json.isInstancedInterleavedBuffer = true;
+		json.type = 'InstancedInterleavedBuffer';
 		json.meshPerAttribute = this.meshPerAttribute;
 
 		return json;

+ 2 - 1
src/core/InterleavedBuffer.js

@@ -279,8 +279,9 @@ class InterleavedBuffer {
 
 		return {
 			uuid: this.uuid,
+			type: 'InterleavedBuffer',
+			arrayType: this.array.constructor.name,
 			buffer: this.array.buffer._uuid,
-			type: this.array.constructor.name,
 			stride: this.stride
 		};
 

+ 1 - 1
src/core/InterleavedBufferAttribute.js

@@ -532,7 +532,7 @@ class InterleavedBufferAttribute {
 			}
 
 			return {
-				isInterleavedBufferAttribute: true,
+				type: 'InterleavedBufferAttribute',
 				itemSize: this.itemSize,
 				data: this.data.uuid,
 				offset: this.offset,

+ 75 - 28
src/core/Object3D.js

@@ -1283,7 +1283,7 @@ class Object3D extends EventDispatcher {
 			};
 
 			output.metadata = {
-				version: 4.7,
+				version: 5,
 				type: 'Object',
 				generator: 'Object3D.toJSON'
 			};
@@ -1513,23 +1513,33 @@ class Object3D extends EventDispatcher {
 
 		if ( isRootObject ) {
 
-			const geometries = extractFromCache( meta.geometries );
-			const materials = extractFromCache( meta.materials );
-			const textures = extractFromCache( meta.textures );
-			const images = extractFromCache( meta.images );
-			const shapes = extractFromCache( meta.shapes );
-			const skeletons = extractFromCache( meta.skeletons );
-			const animations = extractFromCache( meta.animations );
-			const nodes = extractFromCache( meta.nodes );
-
-			if ( geometries.length > 0 ) output.geometries = geometries;
-			if ( materials.length > 0 ) output.materials = materials;
-			if ( textures.length > 0 ) output.textures = textures;
-			if ( images.length > 0 ) output.images = images;
-			if ( shapes.length > 0 ) output.shapes = shapes;
-			if ( skeletons.length > 0 ) output.skeletons = skeletons;
-			if ( animations.length > 0 ) output.animations = animations;
-			if ( nodes.length > 0 ) output.nodes = nodes;
+			// Remove metadata and uuid (redundant since uuid is the key) from each item
+			const collections = [ meta.geometries, meta.materials, meta.textures, meta.images, meta.shapes, meta.skeletons, meta.animations, meta.nodes ];
+
+			for ( const collection of collections ) {
+
+				for ( const key in collection ) {
+
+					delete collection[ key ].metadata;
+					delete collection[ key ].uuid;
+
+				}
+
+			}
+
+			// Extract buffers from geometries to top-level
+			const buffers = extractBuffers( meta.geometries );
+			if ( Object.keys( buffers ).length > 0 ) output.buffers = buffers;
+
+			// Output collections as UUID-keyed objects
+			if ( Object.keys( meta.geometries ).length > 0 ) output.geometries = meta.geometries;
+			if ( Object.keys( meta.materials ).length > 0 ) output.materials = meta.materials;
+			if ( Object.keys( meta.textures ).length > 0 ) output.textures = meta.textures;
+			if ( Object.keys( meta.images ).length > 0 ) output.images = meta.images;
+			if ( Object.keys( meta.shapes ).length > 0 ) output.shapes = meta.shapes;
+			if ( Object.keys( meta.skeletons ).length > 0 ) output.skeletons = meta.skeletons;
+			if ( Object.keys( meta.animations ).length > 0 ) output.animations = meta.animations;
+			if ( Object.keys( meta.nodes ).length > 0 ) output.nodes = meta.nodes;
 
 		}
 
@@ -1537,21 +1547,58 @@ class Object3D extends EventDispatcher {
 
 		return output;
 
-		// extract data from the cache hash
-		// remove metadata on each item
-		// and return as array
-		function extractFromCache( cache ) {
+		// Extract buffer data from geometries to top-level buffers object
+		function extractBuffers( geometries ) {
+
+			const buffers = {};
+
+			for ( const uuid in geometries ) {
+
+				const geometry = geometries[ uuid ];
+				const data = geometry.data;
+
+				if ( data === undefined ) continue;
+
+				// Extract arrayBuffers as typed array entries
+				if ( data.arrayBuffers !== undefined ) {
+
+					for ( const bufferUuid in data.arrayBuffers ) {
+
+						if ( buffers[ bufferUuid ] === undefined ) {
 
-			const values = [];
-			for ( const key in cache ) {
+							buffers[ bufferUuid ] = {
+								type: 'Uint32Array',
+								array: data.arrayBuffers[ bufferUuid ]
+							};
 
-				const data = cache[ key ];
-				delete data.metadata;
-				values.push( data );
+						}
+
+					}
+
+					delete data.arrayBuffers;
+
+				}
+
+				// Extract interleavedBuffers
+				if ( data.interleavedBuffers !== undefined ) {
+
+					for ( const bufferUuid in data.interleavedBuffers ) {
+
+						if ( buffers[ bufferUuid ] === undefined ) {
+
+							buffers[ bufferUuid ] = data.interleavedBuffers[ bufferUuid ];
+
+						}
+
+					}
+
+					delete data.interleavedBuffers;
+
+				}
 
 			}
 
-			return values;
+			return buffers;
 
 		}
 

+ 44 - 7
src/loaders/BufferGeometryLoader.js

@@ -7,6 +7,7 @@ import { InstancedBufferGeometry } from '../core/InstancedBufferGeometry.js';
 import { InstancedBufferAttribute } from '../core/InstancedBufferAttribute.js';
 import { InterleavedBufferAttribute } from '../core/InterleavedBufferAttribute.js';
 import { InterleavedBuffer } from '../core/InterleavedBuffer.js';
+import { InstancedInterleavedBuffer } from '../core/InstancedInterleavedBuffer.js';
 import { getTypedArray, error } from '../utils.js';
 
 /**
@@ -99,8 +100,27 @@ class BufferGeometryLoader extends Loader {
 
 			const buffer = getArrayBuffer( json, interleavedBuffer.buffer );
 
-			const array = getTypedArray( interleavedBuffer.type, buffer );
-			const ib = new InterleavedBuffer( array, interleavedBuffer.stride );
+			// Use arrayType for v5 format, fall back to type for v4
+			const arrayType = interleavedBuffer.arrayType || interleavedBuffer.type;
+			const array = getTypedArray( arrayType, buffer );
+
+			let ib;
+
+			if ( interleavedBuffer.type === 'InstancedInterleavedBuffer' ) {
+
+				ib = new InstancedInterleavedBuffer( array, interleavedBuffer.stride, interleavedBuffer.meshPerAttribute );
+
+			} else if ( interleavedBuffer.isInstancedInterleavedBuffer ) {
+
+				// v4 backwards compatibility
+				ib = new InstancedInterleavedBuffer( array, interleavedBuffer.stride, interleavedBuffer.meshPerAttribute );
+
+			} else {
+
+				ib = new InterleavedBuffer( array, interleavedBuffer.stride );
+
+			}
+
 			ib.uuid = interleavedBuffer.uuid;
 
 			interleavedBufferMap[ uuid ] = ib;
@@ -142,16 +162,30 @@ class BufferGeometryLoader extends Loader {
 			const attribute = attributes[ key ];
 			let bufferAttribute;
 
-			if ( attribute.isInterleavedBufferAttribute ) {
+			// Check for interleaved buffer attribute (support both v4 and v5 formats)
+			const isInterleaved = attribute.isInterleavedBufferAttribute || attribute.type === 'InterleavedBufferAttribute';
+
+			if ( isInterleaved ) {
 
 				const interleavedBuffer = getInterleavedBuffer( json.data, attribute.data );
 				bufferAttribute = new InterleavedBufferAttribute( interleavedBuffer, attribute.itemSize, attribute.offset, attribute.normalized );
 
 			} else {
 
-				const typedArray = getTypedArray( attribute.type, attribute.array );
-				const bufferAttributeConstr = attribute.isInstancedBufferAttribute ? InstancedBufferAttribute : BufferAttribute;
-				bufferAttribute = new bufferAttributeConstr( typedArray, attribute.itemSize, attribute.normalized );
+				// Check for instanced buffer attribute (support both v4 and v5 formats)
+				const isInstanced = attribute.isInstancedBufferAttribute || attribute.type === 'InstancedBufferAttribute';
+
+				const typedArray = getTypedArray( isInstanced ? attribute.arrayType || 'Float32Array' : attribute.type, attribute.array );
+
+				if ( isInstanced ) {
+
+					bufferAttribute = new InstancedBufferAttribute( typedArray, attribute.itemSize, attribute.normalized, attribute.meshPerAttribute );
+
+				} else {
+
+					bufferAttribute = new BufferAttribute( typedArray, attribute.itemSize, attribute.normalized );
+
+				}
 
 			}
 
@@ -177,7 +211,10 @@ class BufferGeometryLoader extends Loader {
 					const attribute = attributeArray[ i ];
 					let bufferAttribute;
 
-					if ( attribute.isInterleavedBufferAttribute ) {
+					// Check for interleaved buffer attribute (support both v4 and v5 formats)
+					const isInterleaved = attribute.isInterleavedBufferAttribute || attribute.type === 'InterleavedBufferAttribute';
+
+					if ( isInterleaved ) {
 
 						const interleavedBuffer = getInterleavedBuffer( json.data, attribute.data );
 						bufferAttribute = new InterleavedBufferAttribute( interleavedBuffer, attribute.itemSize, attribute.offset, attribute.normalized );

+ 143 - 33
src/loaders/ObjectLoader.js

@@ -194,6 +194,10 @@ class ObjectLoader extends Loader {
 	 */
 	parse( json, onLoad ) {
 
+		// Prepare JSON for parsing
+		json = this._convertLegacyCollections( json );
+		json = this._distributeBuffers( json );
+
 		const animations = this.parseAnimations( json.animations );
 		const shapes = this.parseShapes( json.shapes );
 		const geometries = this.parseGeometries( json.geometries, shapes );
@@ -246,6 +250,10 @@ class ObjectLoader extends Loader {
 	 */
 	async parseAsync( json ) {
 
+		// Prepare JSON for parsing
+		json = this._convertLegacyCollections( json );
+		json = this._distributeBuffers( json );
+
 		const animations = this.parseAnimations( json.animations );
 		const shapes = this.parseShapes( json.shapes );
 		const geometries = this.parseGeometries( json.geometries, shapes );
@@ -267,17 +275,103 @@ class ObjectLoader extends Loader {
 
 	// internals
 
+	// Convert array to object keyed by uuid (for backwards compatibility)
+	_toObject( json ) {
+
+		if ( json === undefined || ! Array.isArray( json ) ) return json;
+
+		const obj = {};
+
+		for ( let i = 0; i < json.length; i ++ ) {
+
+			const item = json[ i ];
+			obj[ item.uuid ] = item;
+
+		}
+
+		return obj;
+
+	}
+
+	// Convert legacy v4 array collections to v5 object collections
+	_convertLegacyCollections( json ) {
+
+		const converted = { ...json };
+
+		converted.geometries = this._toObject( json.geometries );
+		converted.materials = this._toObject( json.materials );
+		converted.textures = this._toObject( json.textures );
+		converted.images = this._toObject( json.images );
+		converted.shapes = this._toObject( json.shapes );
+		converted.skeletons = this._toObject( json.skeletons );
+		converted.animations = this._toObject( json.animations );
+
+		return converted;
+
+	}
+
+	// Distribute top-level buffers into each geometry's data
+	_distributeBuffers( json ) {
+
+		if ( json.buffers === undefined || json.geometries === undefined ) {
+
+			return json;
+
+		}
+
+		const distributed = { ...json, geometries: { ...json.geometries } };
+
+		for ( const uuid in distributed.geometries ) {
+
+			const geometry = { ...distributed.geometries[ uuid ] };
+
+			if ( geometry.data !== undefined ) {
+
+				geometry.data = { ...geometry.data };
+				geometry.data.interleavedBuffers = { ...geometry.data.interleavedBuffers };
+				geometry.data.arrayBuffers = { ...geometry.data.arrayBuffers };
+
+				for ( const bufferUuid in json.buffers ) {
+
+					const buffer = json.buffers[ bufferUuid ];
+
+					if ( buffer.type === 'InterleavedBuffer' || buffer.type === 'InstancedInterleavedBuffer' ) {
+
+						geometry.data.interleavedBuffers[ bufferUuid ] = { ...buffer, uuid: bufferUuid };
+
+					} else {
+
+						geometry.data.arrayBuffers[ bufferUuid ] = buffer.array;
+
+					}
+
+				}
+
+				distributed.geometries[ uuid ] = geometry;
+
+			}
+
+		}
+
+		return distributed;
+
+	}
+
 	parseShapes( json ) {
 
 		const shapes = {};
 
+		json = this._toObject( json );
+
 		if ( json !== undefined ) {
 
-			for ( let i = 0, l = json.length; i < l; i ++ ) {
+			for ( const uuid in json ) {
+
+				const data = json[ uuid ];
 
-				const shape = new Shape().fromJSON( json[ i ] );
+				const shape = new Shape().fromJSON( data );
 
-				shapes[ shape.uuid ] = shape;
+				shapes[ uuid ] = shape;
 
 			}
 
@@ -302,13 +396,17 @@ class ObjectLoader extends Loader {
 
 		// create skeletons
 
+		json = this._toObject( json );
+
 		if ( json !== undefined ) {
 
-			for ( let i = 0, l = json.length; i < l; i ++ ) {
+			for ( const uuid in json ) {
 
-				const skeleton = new Skeleton().fromJSON( json[ i ], bones );
+				const data = json[ uuid ];
 
-				skeletons[ skeleton.uuid ] = skeleton;
+				const skeleton = new Skeleton().fromJSON( data, bones );
+
+				skeletons[ uuid ] = skeleton;
 
 			}
 
@@ -322,14 +420,16 @@ class ObjectLoader extends Loader {
 
 		const geometries = {};
 
+		json = this._toObject( json );
+
 		if ( json !== undefined ) {
 
 			const bufferGeometryLoader = new BufferGeometryLoader();
 
-			for ( let i = 0, l = json.length; i < l; i ++ ) {
+			for ( const uuid in json ) {
 
 				let geometry;
-				const data = json[ i ];
+				const data = json[ uuid ];
 
 				switch ( data.type ) {
 
@@ -353,12 +453,12 @@ class ObjectLoader extends Loader {
 
 				}
 
-				geometry.uuid = data.uuid;
+				geometry.uuid = uuid;
 
 				if ( data.name !== undefined ) geometry.name = data.name;
 				if ( data.userData !== undefined ) geometry.userData = data.userData;
 
-				geometries[ data.uuid ] = geometry;
+				geometries[ uuid ] = geometry;
 
 			}
 
@@ -373,22 +473,24 @@ class ObjectLoader extends Loader {
 		const cache = {}; // MultiMaterial
 		const materials = {};
 
+		json = this._toObject( json );
+
 		if ( json !== undefined ) {
 
 			const loader = new MaterialLoader();
 			loader.setTextures( textures );
 
-			for ( let i = 0, l = json.length; i < l; i ++ ) {
+			for ( const uuid in json ) {
 
-				const data = json[ i ];
+				const data = json[ uuid ];
 
-				if ( cache[ data.uuid ] === undefined ) {
+				if ( cache[ uuid ] === undefined ) {
 
-					cache[ data.uuid ] = loader.parse( data );
+					cache[ uuid ] = loader.parse( data );
 
 				}
 
-				materials[ data.uuid ] = cache[ data.uuid ];
+				materials[ uuid ] = cache[ uuid ];
 
 			}
 
@@ -402,15 +504,17 @@ class ObjectLoader extends Loader {
 
 		const animations = {};
 
+		json = this._toObject( json );
+
 		if ( json !== undefined ) {
 
-			for ( let i = 0; i < json.length; i ++ ) {
+			for ( const uuid in json ) {
 
-				const data = json[ i ];
+				const data = json[ uuid ];
 
 				const clip = AnimationClip.parse( data );
 
-				animations[ clip.uuid ] = clip;
+				animations[ uuid ] = clip;
 
 			}
 
@@ -474,16 +578,18 @@ class ObjectLoader extends Loader {
 
 		}
 
-		if ( json !== undefined && json.length > 0 ) {
+		json = this._toObject( json );
+
+		if ( json !== undefined && Object.keys( json ).length > 0 ) {
 
 			const manager = new LoadingManager( onLoad );
 
 			loader = new ImageLoader( manager );
 			loader.setCrossOrigin( this.crossOrigin );
 
-			for ( let i = 0, il = json.length; i < il; i ++ ) {
+			for ( const uuid in json ) {
 
-				const image = json[ i ];
+				const image = json[ uuid ];
 				const url = image.url;
 
 				if ( Array.isArray( url ) ) {
@@ -516,14 +622,14 @@ class ObjectLoader extends Loader {
 
 					}
 
-					images[ image.uuid ] = new Source( imageArray );
+					images[ uuid ] = new Source( imageArray );
 
 				} else {
 
 					// load single image
 
 					const deserializedImage = deserializeImage( image.url );
-					images[ image.uuid ] = new Source( deserializedImage );
+					images[ uuid ] = new Source( deserializedImage );
 
 
 				}
@@ -573,14 +679,16 @@ class ObjectLoader extends Loader {
 
 		}
 
-		if ( json !== undefined && json.length > 0 ) {
+		json = this._toObject( json );
+
+		if ( json !== undefined && Object.keys( json ).length > 0 ) {
 
 			loader = new ImageLoader( this.manager );
 			loader.setCrossOrigin( this.crossOrigin );
 
-			for ( let i = 0, il = json.length; i < il; i ++ ) {
+			for ( const uuid in json ) {
 
-				const image = json[ i ];
+				const image = json[ uuid ];
 				const url = image.url;
 
 				if ( Array.isArray( url ) ) {
@@ -613,14 +721,14 @@ class ObjectLoader extends Loader {
 
 					}
 
-					images[ image.uuid ] = new Source( imageArray );
+					images[ uuid ] = new Source( imageArray );
 
 				} else {
 
 					// load single image
 
 					const deserializedImage = await deserializeImage( image.url );
-					images[ image.uuid ] = new Source( deserializedImage );
+					images[ uuid ] = new Source( deserializedImage );
 
 				}
 
@@ -646,15 +754,17 @@ class ObjectLoader extends Loader {
 
 		const textures = {};
 
+		json = this._toObject( json );
+
 		if ( json !== undefined ) {
 
-			for ( let i = 0, l = json.length; i < l; i ++ ) {
+			for ( const uuid in json ) {
 
-				const data = json[ i ];
+				const data = json[ uuid ];
 
 				if ( data.image === undefined ) {
 
-					warn( 'ObjectLoader: No "image" specified for', data.uuid );
+					warn( 'ObjectLoader: No "image" specified for', uuid );
 
 				}
 
@@ -693,7 +803,7 @@ class ObjectLoader extends Loader {
 
 				texture.source = source;
 
-				texture.uuid = data.uuid;
+				texture.uuid = uuid;
 
 				if ( data.name !== undefined ) texture.name = data.name;
 
@@ -730,7 +840,7 @@ class ObjectLoader extends Loader {
 
 				if ( data.userData !== undefined ) texture.userData = data.userData;
 
-				textures[ data.uuid ] = texture;
+				textures[ uuid ] = texture;
 
 			}
 

+ 1 - 1
test/unit/src/core/BufferGeometry.tests.js

@@ -552,7 +552,7 @@ export default QUnit.module( 'Core', () => {
 			let j = a.toJSON();
 			const gold = {
 				'metadata': {
-					'version': 4.7,
+					'version': 5,
 					'type': 'BufferGeometry',
 					'generator': 'BufferGeometry.toJSON'
 				},

+ 1 - 1
test/unit/src/core/Object3D.tests.js

@@ -1084,7 +1084,7 @@ export default QUnit.module( 'Core', () => {
 
 			const gold = {
 				'metadata': {
-					'version': 4.7,
+					'version': 5,
 					'type': 'Object',
 					'generator': 'Object3D.toJSON'
 				},

+ 511 - 0
test/unit/src/loaders/BufferGeometryLoader.json.tests.js

@@ -0,0 +1,511 @@
+/**
+ * Tests for BufferGeometryLoader JSON format compatibility
+ */
+
+import { BufferGeometryLoader } from '../../../../src/loaders/BufferGeometryLoader.js';
+import { BufferGeometry } from '../../../../src/core/BufferGeometry.js';
+import { BufferAttribute } from '../../../../src/core/BufferAttribute.js';
+import { InterleavedBuffer } from '../../../../src/core/InterleavedBuffer.js';
+import { InterleavedBufferAttribute } from '../../../../src/core/InterleavedBufferAttribute.js';
+
+export default QUnit.module( 'Loaders', () => {
+
+	QUnit.module( 'BufferGeometryLoader JSON Format', () => {
+
+		// BASIC BUFFER GEOMETRY TESTS
+
+		QUnit.test( 'parse - basic BufferGeometry with position attribute', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-1',
+				type: 'BufferGeometry',
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0, 1, 0, 0, 0, 1, 0 ],
+							normalized: false
+						}
+					}
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			assert.ok( geometry.isBufferGeometry, 'Parsed geometry is BufferGeometry' );
+			assert.ok( geometry.uuid, 'Geometry has a UUID' );
+
+			const position = geometry.getAttribute( 'position' );
+			assert.ok( position.isBufferAttribute, 'Position attribute exists' );
+			assert.strictEqual( position.count, 3, 'Position has 3 vertices' );
+			assert.strictEqual( position.itemSize, 3, 'Position itemSize is 3' );
+
+		} );
+
+		QUnit.test( 'parse - BufferGeometry with index', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-2',
+				type: 'BufferGeometry',
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0 ],
+							normalized: false
+						}
+					},
+					index: {
+						type: 'Uint16Array',
+						array: [ 0, 1, 2, 1, 3, 2 ]
+					}
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			const index = geometry.getIndex();
+			assert.ok( index, 'Index exists' );
+			assert.strictEqual( index.count, 6, 'Index has 6 entries' );
+			assert.strictEqual( index.array[ 0 ], 0, 'First index is 0' );
+			assert.strictEqual( index.array[ 5 ], 2, 'Last index is 2' );
+
+		} );
+
+		QUnit.test( 'parse - BufferGeometry with groups', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-3',
+				type: 'BufferGeometry',
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0, 1, 0, 0, 0, 1, 0 ],
+							normalized: false
+						}
+					},
+					groups: [
+						{ start: 0, count: 3, materialIndex: 0 },
+						{ start: 3, count: 3, materialIndex: 1 }
+					]
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			assert.strictEqual( geometry.groups.length, 2, 'Two groups parsed' );
+			assert.strictEqual( geometry.groups[ 0 ].materialIndex, 0, 'First group materialIndex is 0' );
+			assert.strictEqual( geometry.groups[ 1 ].materialIndex, 1, 'Second group materialIndex is 1' );
+
+		} );
+
+		QUnit.test( 'parse - BufferGeometry with boundingSphere', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-4',
+				type: 'BufferGeometry',
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0, 1, 0, 0, 0, 1, 0 ],
+							normalized: false
+						}
+					},
+					boundingSphere: {
+						center: [ 0.5, 0.5, 0 ],
+						radius: 1
+					}
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			assert.ok( geometry.boundingSphere, 'BoundingSphere exists' );
+			assert.strictEqual( geometry.boundingSphere.center.x, 0.5, 'Center x is 0.5' );
+			assert.strictEqual( geometry.boundingSphere.center.y, 0.5, 'Center y is 0.5' );
+			assert.strictEqual( geometry.boundingSphere.radius, 1, 'Radius is 1' );
+
+		} );
+
+		// INTERLEAVED BUFFER TESTS
+
+		QUnit.test( 'parse - InterleavedBufferAttribute', ( assert ) => {
+
+			// Create interleaved data: position (3) + uv (2) = stride 5
+			const interleavedArray = new Float32Array( [
+				// vertex 0
+				0, 0, 0, 0, 0,
+				// vertex 1
+				1, 0, 0, 1, 0,
+				// vertex 2
+				0, 1, 0, 0, 1
+			] );
+
+			const arrayBufferUuid = 'ab-1';
+			const interleavedBufferUuid = 'ib-1';
+
+			// Use v4 format with interleavedBuffers and arrayBuffers
+			const json = {
+				uuid: 'test-geom-interleaved',
+				type: 'BufferGeometry',
+				data: {
+					arrayBuffers: {
+						[ arrayBufferUuid ]: Array.from( new Uint32Array( interleavedArray.buffer ) )
+					},
+					interleavedBuffers: {
+						[ interleavedBufferUuid ]: {
+							uuid: interleavedBufferUuid,
+							buffer: arrayBufferUuid,
+							type: 'Float32Array',
+							stride: 5
+						}
+					},
+					attributes: {
+						position: {
+							isInterleavedBufferAttribute: true,
+							itemSize: 3,
+							data: interleavedBufferUuid,
+							offset: 0,
+							normalized: false
+						},
+						uv: {
+							isInterleavedBufferAttribute: true,
+							itemSize: 2,
+							data: interleavedBufferUuid,
+							offset: 3,
+							normalized: false
+						}
+					}
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			const position = geometry.getAttribute( 'position' );
+			const uv = geometry.getAttribute( 'uv' );
+
+			assert.ok( position.isInterleavedBufferAttribute, 'Position is InterleavedBufferAttribute' );
+			assert.ok( uv.isInterleavedBufferAttribute, 'UV is InterleavedBufferAttribute' );
+			assert.strictEqual( position.itemSize, 3, 'Position itemSize is 3' );
+			assert.strictEqual( uv.itemSize, 2, 'UV itemSize is 2' );
+			assert.strictEqual( position.offset, 0, 'Position offset is 0' );
+			assert.strictEqual( uv.offset, 3, 'UV offset is 3' );
+
+			// They should share the same InterleavedBuffer
+			assert.strictEqual( position.data, uv.data, 'Position and UV share same InterleavedBuffer' );
+			assert.strictEqual( position.data.stride, 5, 'Stride is 5' );
+
+			// Verify data
+			assert.strictEqual( position.getX( 0 ), 0, 'Position[0].x = 0' );
+			assert.strictEqual( position.getX( 1 ), 1, 'Position[1].x = 1' );
+			assert.strictEqual( uv.getX( 1 ), 1, 'UV[1].x = 1' );
+			assert.strictEqual( uv.getY( 2 ), 1, 'UV[2].y = 1' );
+
+		} );
+
+		QUnit.test( 'parse - multiple geometries with InterleavedBuffer', ( assert ) => {
+
+			// Test that multiple geometries can each parse interleaved buffer format
+			const interleavedArray = new Float32Array( [
+				0, 0, 0, 1, 0, 0, 0, 1, 0
+			] );
+
+			const arrayBufferUuid = 'shared-ab';
+			const interleavedBufferUuid = 'shared-ib';
+
+			// Create two separate JSON objects with the same buffer structure
+			function createGeometryJson( uuid ) {
+
+				return {
+					uuid: uuid,
+					type: 'BufferGeometry',
+					data: {
+						arrayBuffers: {
+							[ arrayBufferUuid ]: Array.from( new Uint32Array( interleavedArray.buffer ) )
+						},
+						interleavedBuffers: {
+							[ interleavedBufferUuid ]: {
+								uuid: interleavedBufferUuid,
+								buffer: arrayBufferUuid,
+								type: 'Float32Array',
+								stride: 3
+							}
+						},
+						attributes: {
+							position: {
+								isInterleavedBufferAttribute: true,
+								itemSize: 3,
+								data: interleavedBufferUuid,
+								offset: 0,
+								normalized: false
+							}
+						}
+					}
+				};
+
+			}
+
+			const loader = new BufferGeometryLoader();
+			const geometry1 = loader.parse( createGeometryJson( 'geom-1' ) );
+			const geometry2 = loader.parse( createGeometryJson( 'geom-2' ) );
+
+			const pos1 = geometry1.getAttribute( 'position' );
+			const pos2 = geometry2.getAttribute( 'position' );
+
+			assert.ok( pos1.isInterleavedBufferAttribute, 'Geometry 1 position is interleaved' );
+			assert.ok( pos2.isInterleavedBufferAttribute, 'Geometry 2 position is interleaved' );
+
+			// Note: They won't share the same InterleavedBuffer instance since
+			// BufferGeometryLoader creates new instances for each parse call.
+			// But the data should be the same.
+			assert.strictEqual( pos1.getX( 1 ), pos2.getX( 1 ), 'Data values match' );
+
+		} );
+
+		// MORPH ATTRIBUTES TESTS
+
+		QUnit.test( 'parse - morphAttributes', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-morph',
+				type: 'BufferGeometry',
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0, 1, 0, 0, 0, 1, 0 ],
+							normalized: false
+						}
+					},
+					morphAttributes: {
+						position: [
+							{
+								itemSize: 3,
+								type: 'Float32Array',
+								array: [ 0, 0, 1, 1, 0, 1, 0, 1, 1 ],
+								normalized: false,
+								name: 'morph1'
+							}
+						]
+					},
+					morphTargetsRelative: true
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			assert.ok( geometry.morphAttributes.position, 'Morph position exists' );
+			assert.strictEqual( geometry.morphAttributes.position.length, 1, 'One morph target' );
+			assert.strictEqual( geometry.morphAttributes.position[ 0 ].name, 'morph1', 'Morph name preserved' );
+			assert.strictEqual( geometry.morphTargetsRelative, true, 'morphTargetsRelative is true' );
+
+		} );
+
+		// ROUND-TRIP TESTS
+
+		QUnit.test( 'round-trip - basic BufferGeometry', ( assert ) => {
+
+			const geometry = new BufferGeometry();
+			const positions = new Float32Array( [ 0, 0, 0, 1, 0, 0, 0, 1, 0 ] );
+			const normals = new Float32Array( [ 0, 0, 1, 0, 0, 1, 0, 0, 1 ] );
+
+			geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) );
+			geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
+			geometry.setIndex( [ 0, 1, 2 ] );
+
+			const json = geometry.toJSON();
+			const loader = new BufferGeometryLoader();
+			const loaded = loader.parse( json );
+
+			const loadedPos = loaded.getAttribute( 'position' );
+			const loadedNormal = loaded.getAttribute( 'normal' );
+
+			assert.strictEqual( loadedPos.count, 3, 'Position count matches' );
+			assert.strictEqual( loadedNormal.count, 3, 'Normal count matches' );
+			assert.strictEqual( loaded.index.count, 3, 'Index count matches' );
+
+			// Verify values
+			assert.strictEqual( loadedPos.getX( 1 ), 1, 'Position data preserved' );
+			assert.strictEqual( loadedNormal.getZ( 0 ), 1, 'Normal data preserved' );
+
+		} );
+
+		QUnit.test( 'round-trip - InterleavedBufferAttribute', ( assert ) => {
+
+			const geometry = new BufferGeometry();
+
+			// Create interleaved buffer: position (3) + color (3) = stride 6
+			const interleavedData = new Float32Array( [
+				// vertex 0: position + color
+				0, 0, 0, 1, 0, 0,
+				// vertex 1: position + color
+				1, 0, 0, 0, 1, 0,
+				// vertex 2: position + color
+				0, 1, 0, 0, 0, 1
+			] );
+
+			const interleavedBuffer = new InterleavedBuffer( interleavedData, 6 );
+			const positionAttr = new InterleavedBufferAttribute( interleavedBuffer, 3, 0 );
+			const colorAttr = new InterleavedBufferAttribute( interleavedBuffer, 3, 3 );
+
+			geometry.setAttribute( 'position', positionAttr );
+			geometry.setAttribute( 'color', colorAttr );
+
+			// Note: geometry.toJSON() preserves interleaved buffer structure because
+			// BufferGeometry.toJSON() passes data.data to InterleavedBufferAttribute.toJSON().
+			// The interleaved format is maintained in the JSON.
+
+			const json = geometry.toJSON();
+			const loader = new BufferGeometryLoader();
+			const loaded = loader.parse( json );
+
+			const loadedPos = loaded.getAttribute( 'position' );
+			const loadedColor = loaded.getAttribute( 'color' );
+
+			// InterleavedBufferAttributes are preserved
+			assert.ok( loadedPos.isInterleavedBufferAttribute, 'Position is InterleavedBufferAttribute' );
+			assert.ok( loadedColor.isInterleavedBufferAttribute, 'Color is InterleavedBufferAttribute' );
+
+			// They should share the same InterleavedBuffer
+			assert.strictEqual( loadedPos.data, loadedColor.data, 'Position and color share same InterleavedBuffer' );
+			assert.strictEqual( loadedPos.data.stride, 6, 'Stride preserved' );
+
+			// Verify data integrity
+			assert.strictEqual( loadedPos.getX( 1 ), 1, 'Position[1].x = 1' );
+			assert.strictEqual( loadedColor.getX( 0 ), 1, 'Color[0].x = 1 (red)' );
+			assert.strictEqual( loadedColor.getY( 1 ), 1, 'Color[1].y = 1 (green)' );
+			assert.strictEqual( loadedColor.getZ( 2 ), 1, 'Color[2].z = 1 (blue)' );
+
+		} );
+
+		// ATTRIBUTE TYPES TESTS
+
+		QUnit.test( 'parse - various attribute types', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-types',
+				type: 'BufferGeometry',
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0, 1, 0, 0, 0, 1, 0 ],
+							normalized: false
+						},
+						index8: {
+							itemSize: 1,
+							type: 'Uint8Array',
+							array: [ 0, 1, 2 ],
+							normalized: false
+						},
+						index16: {
+							itemSize: 1,
+							type: 'Uint16Array',
+							array: [ 0, 1, 2 ],
+							normalized: false
+						},
+						index32: {
+							itemSize: 1,
+							type: 'Uint32Array',
+							array: [ 0, 1, 2 ],
+							normalized: false
+						},
+						normalizedColor: {
+							itemSize: 3,
+							type: 'Uint8Array',
+							array: [ 255, 0, 0, 0, 255, 0, 0, 0, 255 ],
+							normalized: true
+						}
+					}
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			assert.ok( geometry.getAttribute( 'position' ).array instanceof Float32Array, 'Float32Array' );
+			assert.ok( geometry.getAttribute( 'index8' ).array instanceof Uint8Array, 'Uint8Array' );
+			assert.ok( geometry.getAttribute( 'index16' ).array instanceof Uint16Array, 'Uint16Array' );
+			assert.ok( geometry.getAttribute( 'index32' ).array instanceof Uint32Array, 'Uint32Array' );
+
+			const normalizedColor = geometry.getAttribute( 'normalizedColor' );
+			assert.strictEqual( normalizedColor.normalized, true, 'Normalized flag preserved' );
+
+		} );
+
+		// NAME AND USAGE TESTS
+
+		QUnit.test( 'parse - attribute name and usage', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-usage',
+				type: 'BufferGeometry',
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0 ],
+							normalized: false,
+							name: 'customPositionName',
+							usage: 35048 // DynamicDrawUsage
+						}
+					}
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			const position = geometry.getAttribute( 'position' );
+			assert.strictEqual( position.name, 'customPositionName', 'Attribute name preserved' );
+			assert.strictEqual( position.usage, 35048, 'Attribute usage preserved' );
+
+		} );
+
+		// GEOMETRY METADATA TESTS
+
+		QUnit.test( 'parse - geometry name and userData', ( assert ) => {
+
+			const json = {
+				uuid: 'test-geom-meta',
+				type: 'BufferGeometry',
+				name: 'MyCustomGeometry',
+				userData: { custom: 'data', number: 42 },
+				data: {
+					attributes: {
+						position: {
+							itemSize: 3,
+							type: 'Float32Array',
+							array: [ 0, 0, 0 ],
+							normalized: false
+						}
+					}
+				}
+			};
+
+			const loader = new BufferGeometryLoader();
+			const geometry = loader.parse( json );
+
+			assert.strictEqual( geometry.name, 'MyCustomGeometry', 'Geometry name preserved' );
+			assert.deepEqual( geometry.userData, { custom: 'data', number: 42 }, 'UserData preserved' );
+
+		} );
+
+	} );
+
+} );

+ 682 - 0
test/unit/src/loaders/ObjectLoader.json5.tests.js

@@ -0,0 +1,682 @@
+/**
+ * Tests for ObjectLoader JSON v5 format
+ */
+
+import { ObjectLoader } from '../../../../src/loaders/ObjectLoader.js';
+
+import { Scene } from '../../../../src/scenes/Scene.js';
+import { Mesh } from '../../../../src/objects/Mesh.js';
+import { BoxGeometry } from '../../../../src/geometries/BoxGeometry.js';
+import { SphereGeometry } from '../../../../src/geometries/SphereGeometry.js';
+import { MeshBasicMaterial } from '../../../../src/materials/MeshBasicMaterial.js';
+import { BufferGeometry } from '../../../../src/core/BufferGeometry.js';
+import { Float32BufferAttribute } from '../../../../src/core/BufferAttribute.js';
+import { InstancedBufferGeometry } from '../../../../src/core/InstancedBufferGeometry.js';
+import { InstancedBufferAttribute } from '../../../../src/core/InstancedBufferAttribute.js';
+import { InterleavedBuffer } from '../../../../src/core/InterleavedBuffer.js';
+import { InterleavedBufferAttribute } from '../../../../src/core/InterleavedBufferAttribute.js';
+import { InstancedInterleavedBuffer } from '../../../../src/core/InstancedInterleavedBuffer.js';
+import { InstancedMesh } from '../../../../src/objects/InstancedMesh.js';
+
+export default QUnit.module( 'Loaders', () => {
+
+	QUnit.module( 'ObjectLoader JSON v5 Format', () => {
+
+		// V5 FORMAT (objects keyed by uuid) TESTS
+
+		QUnit.test( 'parse v5 format - geometries as object', ( assert ) => {
+
+			const json = {
+				metadata: { version: 5, type: 'Object', generator: 'Object3D.toJSON' },
+				geometries: {
+					'geom-1': {
+						uuid: 'geom-1',
+						type: 'BoxGeometry',
+						width: 2, height: 2, depth: 2
+					},
+					'geom-2': {
+						uuid: 'geom-2',
+						type: 'SphereGeometry',
+						radius: 1
+					}
+				},
+				materials: {
+					'mat-1': {
+						uuid: 'mat-1',
+						type: 'MeshBasicMaterial',
+						color: 0x0000ff
+					}
+				},
+				object: {
+					uuid: 'root',
+					type: 'Scene',
+					children: [
+						{
+							uuid: 'mesh-1',
+							type: 'Mesh',
+							geometry: 'geom-1',
+							material: 'mat-1'
+						},
+						{
+							uuid: 'mesh-2',
+							type: 'Mesh',
+							geometry: 'geom-2',
+							material: 'mat-1'
+						}
+					]
+				}
+			};
+
+			const loader = new ObjectLoader();
+			const scene = loader.parse( json );
+
+			assert.ok( scene.isScene, 'Parsed v5 scene correctly' );
+			assert.strictEqual( scene.children.length, 2, 'Scene has two children' );
+			assert.ok( scene.children[ 0 ].geometry.isBufferGeometry, 'First mesh has geometry' );
+			assert.ok( scene.children[ 1 ].geometry.isBufferGeometry, 'Second mesh has geometry' );
+
+		} );
+
+		QUnit.test( 'parse v5 format - materials as object', ( assert ) => {
+
+			const json = {
+				metadata: { version: 5, type: 'Object', generator: 'Object3D.toJSON' },
+				materials: {
+					'mat-1': {
+						uuid: 'mat-1',
+						type: 'MeshStandardMaterial',
+						color: 0xff00ff,
+						roughness: 0.8,
+						metalness: 0.2
+					}
+				},
+				geometries: {
+					'geom-1': {
+						uuid: 'geom-1',
+						type: 'BoxGeometry',
+						width: 1, height: 1, depth: 1
+					}
+				},
+				object: {
+					uuid: 'root',
+					type: 'Scene',
+					children: [
+						{
+							uuid: 'mesh-1',
+							type: 'Mesh',
+							geometry: 'geom-1',
+							material: 'mat-1'
+						}
+					]
+				}
+			};
+
+			const loader = new ObjectLoader();
+			const scene = loader.parse( json );
+
+			const material = scene.children[ 0 ].material;
+			assert.ok( material.isMeshStandardMaterial, 'Material type correct' );
+			assert.strictEqual( material.roughness, 0.8, 'Roughness preserved' );
+			assert.strictEqual( material.metalness, 0.2, 'Metalness preserved' );
+
+		} );
+
+		QUnit.test( 'parse v5 format - shapes as object', ( assert ) => {
+
+			const json = {
+				metadata: { version: 5, type: 'Object', generator: 'Object3D.toJSON' },
+				shapes: {
+					'shape-1': {
+						uuid: 'shape-1',
+						type: 'Shape',
+						arcLengthDivisions: 200,
+						autoClose: false,
+						currentPoint: [ 0, 0 ],
+						holes: [],
+						curves: [
+							{
+								type: 'LineCurve',
+								arcLengthDivisions: 200,
+								v1: [ 0, 0 ],
+								v2: [ 1, 0 ]
+							},
+							{
+								type: 'LineCurve',
+								arcLengthDivisions: 200,
+								v1: [ 1, 0 ],
+								v2: [ 0, 1 ]
+							},
+							{
+								type: 'LineCurve',
+								arcLengthDivisions: 200,
+								v1: [ 0, 1 ],
+								v2: [ 0, 0 ]
+							}
+						]
+					}
+				},
+				geometries: {
+					'geom-1': {
+						uuid: 'geom-1',
+						type: 'ShapeGeometry',
+						shapes: [ 'shape-1' ]
+					}
+				},
+				materials: {
+					'mat-1': {
+						uuid: 'mat-1',
+						type: 'MeshBasicMaterial',
+						color: 0xffffff
+					}
+				},
+				object: {
+					uuid: 'root',
+					type: 'Scene',
+					children: [
+						{
+							uuid: 'mesh-1',
+							type: 'Mesh',
+							geometry: 'geom-1',
+							material: 'mat-1'
+						}
+					]
+				}
+			};
+
+			const loader = new ObjectLoader();
+			const scene = loader.parse( json );
+
+			assert.ok( scene.children[ 0 ].geometry.isBufferGeometry, 'ShapeGeometry parsed from v5 shapes object' );
+
+		} );
+
+		QUnit.test( 'parse v5 format - animations as object', ( assert ) => {
+
+			const json = {
+				metadata: { version: 5, type: 'Object', generator: 'Object3D.toJSON' },
+				animations: {
+					'clip-1': {
+						uuid: 'clip-1',
+						name: 'FadeIn',
+						duration: 2,
+						tracks: [
+							{
+								type: 'number',
+								name: '.opacity',
+								times: [ 0, 2 ],
+								values: [ 0, 1 ]
+							}
+						]
+					}
+				},
+				geometries: {
+					'geom-1': {
+						uuid: 'geom-1',
+						type: 'BoxGeometry',
+						width: 1, height: 1, depth: 1
+					}
+				},
+				materials: {
+					'mat-1': {
+						uuid: 'mat-1',
+						type: 'MeshBasicMaterial',
+						color: 0xffffff
+					}
+				},
+				object: {
+					uuid: 'root',
+					type: 'Scene',
+					animations: [ 'clip-1' ],
+					children: [
+						{
+							uuid: 'mesh-1',
+							type: 'Mesh',
+							geometry: 'geom-1',
+							material: 'mat-1'
+						}
+					]
+				}
+			};
+
+			const loader = new ObjectLoader();
+			const scene = loader.parse( json );
+
+			assert.strictEqual( scene.animations.length, 1, 'Animation parsed from v5 object' );
+			assert.strictEqual( scene.animations[ 0 ].name, 'FadeIn', 'Animation name preserved' );
+			assert.strictEqual( scene.animations[ 0 ].duration, 2, 'Animation duration preserved' );
+
+		} );
+
+		// ROUND-TRIP TESTS (serialize then parse)
+
+		QUnit.test( 'round-trip - simple scene', ( assert ) => {
+
+			const scene = new Scene();
+			const geometry = new BoxGeometry( 1, 1, 1 );
+			const material = new MeshBasicMaterial( { color: 0xff0000 } );
+			const mesh = new Mesh( geometry, material );
+			mesh.position.set( 1, 2, 3 );
+			mesh.updateMatrix(); // Important: update matrix before serialization
+			scene.add( mesh );
+
+			const json = scene.toJSON();
+			const loader = new ObjectLoader();
+			const loadedScene = loader.parse( json );
+
+			assert.ok( loadedScene.isScene, 'Loaded scene is a Scene' );
+			assert.strictEqual( loadedScene.children.length, 1, 'Scene has correct number of children' );
+			assert.ok( loadedScene.children[ 0 ].isMesh, 'Child is a Mesh' );
+			assert.strictEqual( loadedScene.children[ 0 ].position.x, 1, 'Position x preserved' );
+			assert.strictEqual( loadedScene.children[ 0 ].position.y, 2, 'Position y preserved' );
+			assert.strictEqual( loadedScene.children[ 0 ].position.z, 3, 'Position z preserved' );
+
+		} );
+
+		QUnit.test( 'round-trip - multiple meshes sharing geometry', ( assert ) => {
+
+			const scene = new Scene();
+			const geometry = new BoxGeometry( 1, 1, 1 );
+			const material1 = new MeshBasicMaterial( { color: 0xff0000 } );
+			const material2 = new MeshBasicMaterial( { color: 0x00ff00 } );
+
+			const mesh1 = new Mesh( geometry, material1 );
+			const mesh2 = new Mesh( geometry, material2 );
+
+			scene.add( mesh1 );
+			scene.add( mesh2 );
+
+			const json = scene.toJSON();
+
+			// Verify geometry deduplication in v5 format
+			assert.strictEqual( Object.keys( json.geometries ).length, 1, 'Shared geometry stored once' );
+
+			const loader = new ObjectLoader();
+			const loadedScene = loader.parse( json );
+
+			assert.strictEqual( loadedScene.children.length, 2, 'Both meshes loaded' );
+
+			// In loaded scene, geometries are separate instances but have same data
+			assert.ok( loadedScene.children[ 0 ].geometry.isBufferGeometry, 'First mesh has geometry' );
+			assert.ok( loadedScene.children[ 1 ].geometry.isBufferGeometry, 'Second mesh has geometry' );
+
+		} );
+
+		QUnit.test( 'round-trip - multiple meshes sharing material', ( assert ) => {
+
+			const scene = new Scene();
+			const geometry1 = new BoxGeometry( 1, 1, 1 );
+			const geometry2 = new SphereGeometry( 0.5 );
+			const material = new MeshBasicMaterial( { color: 0xff0000 } );
+
+			const mesh1 = new Mesh( geometry1, material );
+			const mesh2 = new Mesh( geometry2, material );
+
+			scene.add( mesh1 );
+			scene.add( mesh2 );
+
+			const json = scene.toJSON();
+
+			// Verify material deduplication in v5 format
+			assert.strictEqual( Object.keys( json.materials ).length, 1, 'Shared material stored once' );
+
+			const loader = new ObjectLoader();
+			const loadedScene = loader.parse( json );
+
+			assert.strictEqual( loadedScene.children.length, 2, 'Both meshes loaded' );
+
+		} );
+
+		// BUFFERS TESTS (InterleavedBuffer)
+
+		QUnit.test( 'parse v5 format - buffers with InterleavedBuffer', ( assert ) => {
+
+			// Create interleaved data: position (3) + uv (2) = stride 5
+			const interleavedArray = new Float32Array( [
+				// vertex 0: pos + uv
+				0, 0, 0, 0, 0,
+				// vertex 1: pos + uv
+				1, 0, 0, 1, 0,
+				// vertex 2: pos + uv
+				0, 1, 0, 0, 1
+			] );
+
+			const json = {
+				metadata: { version: 5, type: 'Object', generator: 'Object3D.toJSON' },
+				buffers: {
+					'arraybuffer-1': {
+						type: 'Float32Array',
+						array: Array.from( new Uint32Array( interleavedArray.buffer ) )
+					},
+					'interleaved-1': {
+						type: 'InterleavedBuffer',
+						buffer: 'arraybuffer-1',
+						arrayType: 'Float32Array',
+						stride: 5
+					}
+				},
+				geometries: {
+					'geom-1': {
+						type: 'BufferGeometry',
+						data: {
+							attributes: {
+								position: {
+									type: 'InterleavedBufferAttribute',
+									itemSize: 3,
+									data: 'interleaved-1',
+									offset: 0,
+									normalized: false
+								},
+								uv: {
+									type: 'InterleavedBufferAttribute',
+									itemSize: 2,
+									data: 'interleaved-1',
+									offset: 3,
+									normalized: false
+								}
+							}
+						}
+					}
+				},
+				materials: {
+					'mat-1': {
+						type: 'MeshBasicMaterial',
+						color: 0xffffff
+					}
+				},
+				object: {
+					uuid: 'root',
+					type: 'Scene',
+					children: [
+						{
+							uuid: 'mesh-1',
+							type: 'Mesh',
+							geometry: 'geom-1',
+							material: 'mat-1'
+						}
+					]
+				}
+			};
+
+			const loader = new ObjectLoader();
+			const scene = loader.parse( json );
+
+			const geometry = scene.children[ 0 ].geometry;
+			assert.ok( geometry.isBufferGeometry, 'Geometry parsed' );
+
+			const position = geometry.getAttribute( 'position' );
+			assert.ok( position.isInterleavedBufferAttribute, 'Position is InterleavedBufferAttribute' );
+			assert.strictEqual( position.itemSize, 3, 'Position itemSize is 3' );
+
+			const uv = geometry.getAttribute( 'uv' );
+			assert.ok( uv.isInterleavedBufferAttribute, 'UV is InterleavedBufferAttribute' );
+			assert.strictEqual( uv.itemSize, 2, 'UV itemSize is 2' );
+
+			// Verify they share the same InterleavedBuffer
+			assert.strictEqual( position.data, uv.data, 'Position and UV share same InterleavedBuffer' );
+
+		} );
+
+		QUnit.test( 'round-trip - InterleavedBufferAttribute', ( assert ) => {
+
+			const scene = new Scene();
+
+			// Create a geometry with interleaved attributes
+			const geometry = new BufferGeometry();
+
+			// Interleaved: position (3) + normal (3) = stride 6
+			const interleavedData = new Float32Array( [
+				// vertex 0
+				0, 0, 0, 0, 0, 1,
+				// vertex 1
+				1, 0, 0, 0, 0, 1,
+				// vertex 2
+				0, 1, 0, 0, 0, 1
+			] );
+
+			const interleavedBuffer = new InterleavedBuffer( interleavedData, 6 );
+			const positionAttr = new InterleavedBufferAttribute( interleavedBuffer, 3, 0 );
+			const normalAttr = new InterleavedBufferAttribute( interleavedBuffer, 3, 3 );
+
+			geometry.setAttribute( 'position', positionAttr );
+			geometry.setAttribute( 'normal', normalAttr );
+
+			const material = new MeshBasicMaterial( { color: 0xffffff } );
+			const mesh = new Mesh( geometry, material );
+			scene.add( mesh );
+
+			const json = scene.toJSON();
+
+			// Verify buffers structure exists
+			assert.ok( json.buffers, 'Buffers object exists in JSON' );
+			assert.ok( Object.keys( json.buffers ).length > 0, 'Buffers contains entries' );
+
+			const loader = new ObjectLoader();
+			const loadedScene = loader.parse( json );
+
+			const loadedGeometry = loadedScene.children[ 0 ].geometry;
+			const loadedPosition = loadedGeometry.getAttribute( 'position' );
+			const loadedNormal = loadedGeometry.getAttribute( 'normal' );
+
+			assert.ok( loadedPosition.isInterleavedBufferAttribute, 'Loaded position is InterleavedBufferAttribute' );
+			assert.ok( loadedNormal.isInterleavedBufferAttribute, 'Loaded normal is InterleavedBufferAttribute' );
+			assert.strictEqual( loadedPosition.data, loadedNormal.data, 'They share the same InterleavedBuffer' );
+
+			// Verify data integrity
+			assert.strictEqual( loadedPosition.getX( 1 ), 1, 'Position data preserved' );
+			assert.strictEqual( loadedNormal.getZ( 0 ), 1, 'Normal data preserved' );
+
+		} );
+
+		QUnit.test( 'round-trip - InstancedBufferGeometry with InterleavedBufferAttribute', ( assert ) => {
+
+			const scene = new Scene();
+
+			// Create an instanced geometry with interleaved attributes
+			const geometry = new InstancedBufferGeometry();
+
+			// Interleaved: position (3) + uv (2) = stride 5
+			const interleavedData = new Float32Array( [
+				// vertex 0
+				0, 0, 0, 0, 0,
+				// vertex 1
+				1, 0, 0, 1, 0,
+				// vertex 2
+				0, 1, 0, 0, 1
+			] );
+
+			const interleavedBuffer = new InterleavedBuffer( interleavedData, 5 );
+			const positionAttr = new InterleavedBufferAttribute( interleavedBuffer, 3, 0 );
+			const uvAttr = new InterleavedBufferAttribute( interleavedBuffer, 2, 3 );
+
+			geometry.setAttribute( 'position', positionAttr );
+			geometry.setAttribute( 'uv', uvAttr );
+			geometry.instanceCount = 10;
+
+			const material = new MeshBasicMaterial( { color: 0xffffff } );
+			const mesh = new InstancedMesh( geometry, material, 10 );
+			scene.add( mesh );
+
+			const json = scene.toJSON();
+
+			// Verify buffers structure exists
+			assert.ok( json.buffers, 'Buffers object exists in JSON' );
+			assert.ok( Object.keys( json.buffers ).length > 0, 'Buffers contains entries' );
+
+			const loader = new ObjectLoader();
+			const loadedScene = loader.parse( json );
+
+			const loadedGeometry = loadedScene.children[ 0 ].geometry;
+			const loadedPosition = loadedGeometry.getAttribute( 'position' );
+			const loadedUv = loadedGeometry.getAttribute( 'uv' );
+
+			assert.ok( loadedGeometry.isInstancedBufferGeometry, 'Loaded geometry is InstancedBufferGeometry' );
+			assert.ok( loadedPosition.isInterleavedBufferAttribute, 'Loaded position is InterleavedBufferAttribute' );
+			assert.ok( loadedUv.isInterleavedBufferAttribute, 'Loaded uv is InterleavedBufferAttribute' );
+			assert.strictEqual( loadedPosition.data, loadedUv.data, 'They share the same InterleavedBuffer' );
+			assert.strictEqual( loadedPosition.data.stride, 5, 'Stride is preserved' );
+
+		} );
+
+		QUnit.test( 'round-trip - InstancedBufferAttribute', ( assert ) => {
+
+			const scene = new Scene();
+
+			// Create a geometry with instanced buffer attributes
+			const geometry = new InstancedBufferGeometry();
+
+			// Base geometry (a simple triangle)
+			const positions = new Float32BufferAttribute( [
+				0, 0, 0,
+				1, 0, 0,
+				0, 1, 0
+			], 3 );
+			geometry.setAttribute( 'position', positions );
+
+			// Instance offsets with meshPerAttribute = 1
+			const offsets = new InstancedBufferAttribute(
+				new Float32Array( [
+					0, 0, 0,
+					2, 0, 0,
+					4, 0, 0,
+					6, 0, 0
+				] ),
+				3,
+				false,
+				1
+			);
+			geometry.setAttribute( 'offset', offsets );
+
+			// Instance colors with meshPerAttribute = 2 (each color used for 2 instances)
+			const colors = new InstancedBufferAttribute(
+				new Float32Array( [
+					1, 0, 0,
+					0, 1, 0
+				] ),
+				3,
+				false,
+				2
+			);
+			geometry.setAttribute( 'color', colors );
+
+			geometry.instanceCount = 4;
+
+			const material = new MeshBasicMaterial( { color: 0xffffff } );
+			const mesh = new Mesh( geometry, material );
+			scene.add( mesh );
+
+			const json = scene.toJSON();
+
+			const loader = new ObjectLoader();
+			const loadedScene = loader.parse( json );
+
+			const loadedGeometry = loadedScene.children[ 0 ].geometry;
+			const loadedOffset = loadedGeometry.getAttribute( 'offset' );
+			const loadedColor = loadedGeometry.getAttribute( 'color' );
+
+			assert.ok( loadedGeometry.isInstancedBufferGeometry, 'Loaded geometry is InstancedBufferGeometry' );
+			assert.ok( loadedOffset.isInstancedBufferAttribute, 'Loaded offset is InstancedBufferAttribute' );
+			assert.ok( loadedColor.isInstancedBufferAttribute, 'Loaded color is InstancedBufferAttribute' );
+			assert.strictEqual( loadedOffset.meshPerAttribute, 1, 'Offset meshPerAttribute preserved' );
+			assert.strictEqual( loadedColor.meshPerAttribute, 2, 'Color meshPerAttribute preserved' );
+
+			// Verify data integrity
+			assert.strictEqual( loadedOffset.getX( 2 ), 4, 'Offset data preserved' );
+			assert.strictEqual( loadedColor.getY( 1 ), 1, 'Color data preserved' );
+
+		} );
+
+		QUnit.test( 'round-trip - InstancedInterleavedBuffer', ( assert ) => {
+
+			const scene = new Scene();
+
+			// Create a geometry with instanced interleaved buffer
+			const geometry = new InstancedBufferGeometry();
+
+			// Base geometry (a simple triangle)
+			const positions = new Float32BufferAttribute( [
+				0, 0, 0,
+				1, 0, 0,
+				0, 1, 0
+			], 3 );
+			geometry.setAttribute( 'position', positions );
+
+			// Instanced interleaved data: offset (3) + scale (1) = stride 4
+			// meshPerAttribute = 1 means each value is for one instance
+			const instanceData = new Float32Array( [
+				// instance 0: offset + scale
+				0, 0, 0, 1,
+				// instance 1: offset + scale
+				2, 0, 0, 0.5,
+				// instance 2: offset + scale
+				4, 0, 0, 1.5
+			] );
+
+			const instancedInterleavedBuffer = new InstancedInterleavedBuffer( instanceData, 4, 1 );
+			const offsetAttr = new InterleavedBufferAttribute( instancedInterleavedBuffer, 3, 0 );
+			const scaleAttr = new InterleavedBufferAttribute( instancedInterleavedBuffer, 1, 3 );
+
+			geometry.setAttribute( 'instanceOffset', offsetAttr );
+			geometry.setAttribute( 'instanceScale', scaleAttr );
+			geometry.instanceCount = 3;
+
+			const material = new MeshBasicMaterial( { color: 0xffffff } );
+			const mesh = new Mesh( geometry, material );
+			scene.add( mesh );
+
+			const json = scene.toJSON();
+
+			// Verify buffers structure exists
+			assert.ok( json.buffers, 'Buffers object exists in JSON' );
+			assert.ok( Object.keys( json.buffers ).length > 0, 'Buffers contains entries' );
+
+			const loader = new ObjectLoader();
+			const loadedScene = loader.parse( json );
+
+			const loadedGeometry = loadedScene.children[ 0 ].geometry;
+			const loadedOffset = loadedGeometry.getAttribute( 'instanceOffset' );
+			const loadedScale = loadedGeometry.getAttribute( 'instanceScale' );
+
+			assert.ok( loadedOffset.isInterleavedBufferAttribute, 'Loaded instanceOffset is InterleavedBufferAttribute' );
+			assert.ok( loadedScale.isInterleavedBufferAttribute, 'Loaded instanceScale is InterleavedBufferAttribute' );
+			assert.strictEqual( loadedOffset.data, loadedScale.data, 'They share the same InterleavedBuffer' );
+			assert.ok( loadedOffset.data.isInstancedInterleavedBuffer, 'Buffer is InstancedInterleavedBuffer' );
+			assert.strictEqual( loadedOffset.data.meshPerAttribute, 1, 'meshPerAttribute preserved' );
+			assert.strictEqual( loadedOffset.data.stride, 4, 'Stride preserved' );
+
+			// Verify data integrity
+			assert.strictEqual( loadedOffset.getX( 1 ), 2, 'Offset data preserved' );
+			assert.strictEqual( loadedScale.getX( 1 ), 0.5, 'Scale data preserved' );
+
+		} );
+
+		// VERSION CHECK TESTS
+
+		QUnit.test( 'toJSON produces version 5', ( assert ) => {
+
+			const scene = new Scene();
+			const json = scene.toJSON();
+
+			assert.strictEqual( json.metadata.version, 5, 'Version is 5' );
+
+		} );
+
+		QUnit.test( 'toJSON produces object-keyed collections', ( assert ) => {
+
+			const scene = new Scene();
+			const geometry = new BoxGeometry( 1, 1, 1 );
+			const material = new MeshBasicMaterial( { color: 0xff0000 } );
+			const mesh = new Mesh( geometry, material );
+			scene.add( mesh );
+
+			const json = scene.toJSON();
+
+			assert.ok( ! Array.isArray( json.geometries ), 'Geometries is not an array' );
+			assert.ok( ! Array.isArray( json.materials ), 'Materials is not an array' );
+			assert.strictEqual( typeof json.geometries, 'object', 'Geometries is an object' );
+			assert.strictEqual( typeof json.materials, 'object', 'Materials is an object' );
+
+		} );
+
+	} );
+
+} );

+ 2 - 2
test/unit/utils/qunit-utils.js

@@ -119,7 +119,7 @@ function getDifferingProp( geometryA, geometryB ) {
 // Compare json file with its source geometry.
 function checkGeometryJsonWriting( geom, json ) {
 
-	QUnit.assert.equal( json.metadata.version, '4.7', 'check metadata version' );
+	QUnit.assert.equal( json.metadata.version, 5, 'check metadata version' );
 	QUnit.assert.equalKey( geom, json, 'type' );
 	QUnit.assert.equalKey( geom, json, 'uuid' );
 	QUnit.assert.equal( json.id, undefined, 'should not persist id' );
@@ -268,7 +268,7 @@ function checkLightCopyClone( assert, light ) {
 // Compare json file with its source Light.
 function checkLightJsonWriting( assert, light, json ) {
 
-	assert.equal( json.metadata.version, '4.7', 'check metadata version' );
+	assert.equal( json.metadata.version, 5, 'check metadata version' );
 
 	const object = json.object;
 	assert.equalKey( light, object, 'type' );

粤ICP备19079148号