When our client, a custom furniture manufacturer, approached us about creating an online 3D product configurator, they had already tried and failed with two previous development teams. The challenge was significant: create a photorealistic, interactive 3D experience that would work smoothly across devices, from high-end desktops to budget smartphones.
The conventional wisdom said this was impossible without sacrificing either visual quality or performance. We decided to prove that wrong.
The previous implementations faced several critical issues:
Let me walk you through how we solved each of these challenges with a custom Three.js implementation.
The first breakthrough came when we developed a custom progressive mesh loading system. Instead of loading a single high-poly model, we created multiple level-of-detail (LOD) versions and dynamically swapped them based on device capabilities and camera distance.
Here's the core of our implementation:
class ProgressiveMeshLoader {
constructor(scene, loadingManager) {
this.scene = scene;
this.loadingManager = loadingManager;
this.loader = new THREE.GLTFLoader(this.loadingManager);
this.dracoLoader = new THREE.DRACOLoader();
this.dracoLoader.setDecoderPath('/draco/');
this.loader.setDRACOLoader(this.dracoLoader);
// Device capability detection
this.devicePerformance = this._detectDevicePerformance();
// Cache for loaded models
this.modelCache = new Map();
}
_detectDevicePerformance() {
// Basic performance classification based on hardware
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return 'low';
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
// Check for mobile GPU
if (/adreno|mali|apple gpu/i.test(renderer)) {
// Further classify mobile GPUs
const mobileTier = this._classifyMobileGPU(renderer);
return mobileTier;
}
// Desktop performance detection based on concurrent model load tests
const perfScore = this._runPerformanceBenchmark();
if (perfScore > 80) return 'high';
if (perfScore > 40) return 'medium';
return 'low';
}
async loadModel(productId, variantId, onProgress) {
const cacheKey = `${productId}_${variantId}`;
// Check cache first
if (this.modelCache.has(cacheKey)) {
return this.modelCache.get(cacheKey).clone();
}
// Determine which LOD to load initially based on device performance
const initialLOD = this._getInitialLODForDevice();
// Start with low detail model for immediate display
const lowDetailModel = await this._loadModelVariant(productId, variantId, 'low', onProgress);
this.scene.add(lowDetailModel);
// If device can handle higher detail, load it in the background
if (initialLOD !== 'low') {
// Use Promise.all to load all remaining LODs in parallel
const lodPromises = ['medium', 'high']
.filter(lod => lod !== 'low' && lod <= initialLOD)
.map(lod => this._loadModelVariant(productId, variantId, lod));
// Replace model as higher LODs become available
const higherLODs = await Promise.all(lodPromises);
// Replace the low detail model with the highest LOD available
const highestLOD = higherLODs[higherLODs.length - 1];
this._replaceModel(lowDetailModel, highestLOD);
// Cache the result
this.modelCache.set(cacheKey, highestLOD.clone());
return highestLOD;
}
// For low-end devices, just return the low detail model
this.modelCache.set(cacheKey, lowDetailModel.clone());
return lowDetailModel;
}
// Replace a model in the scene with minimal visual disruption
_replaceModel(oldModel, newModel) {
// Copy transform from old model
newModel.position.copy(oldModel.position);
newModel.rotation.copy(oldModel.rotation);
newModel.scale.copy(oldModel.scale);
// Ensure materials and animations are properly transferred
this._transferMaterials(oldModel, newModel);
this._syncAnimationState(oldModel, newModel);
// Add new model before removing old one to prevent flicker
this.scene.add(newModel);
this.scene.remove(oldModel);
// Clean up to prevent memory leaks
this._disposeModel(oldModel);
}
// Helper to properly dispose Three.js resources
_disposeModel(model) {
model.traverse(node => {
if (node.isMesh) {
node.geometry.dispose();
if (Array.isArray(node.material)) {
node.material.forEach(material => material.dispose());
} else if (node.material) {
node.material.dispose();
}
}
});
}
// Other helper methods...
}
This progressive approach gave us two massive wins:
The next challenge was creating a material system that maintained photorealistic quality while allowing real-time customization. We built a PBR (Physically Based Rendering) material system with dynamic texture compositing:
class MaterialManager {
constructor() {
this.materialTemplates = {};
this.textureCache = new Map();
this.compositeCache = new Map();
this.textureLoader = new THREE.TextureLoader();
}
async loadMaterialTemplates(productId) {
const response = await fetch(`/api/products/${productId}/materials`);
const templates = await response.json();
// Process and store material templates
for (const [id, template] of Object.entries(templates)) {
// Preprocess template properties for faster application
this.materialTemplates[id] = this._preprocessTemplate(template);
}
return this.materialTemplates;
}
// Get or create a texture, with caching
async getTexture(textureUrl) {
if (this.textureCache.has(textureUrl)) {
return this.textureCache.get(textureUrl);
}
return new Promise((resolve, reject) => {
this.textureLoader.load(
textureUrl,
texture => {
// Configure texture properties
texture.encoding = THREE.sRGBEncoding;
texture.flipY = false;
// Store in cache
this.textureCache.set(textureUrl, texture);
resolve(texture);
},
undefined,
error => reject(error)
);
});
}
// Create a composite texture by blending multiple layers
async createCompositeTexture(baseTextureUrl, overlayTextureUrl, blendMode, opacity) {
const cacheKey = `${baseTextureUrl}|${overlayTextureUrl}|${blendMode}|${opacity}`;
if (this.compositeCache.has(cacheKey)) {
return this.compositeCache.get(cacheKey);
}
// Load component textures
const [baseTexture, overlayTexture] = await Promise.all([
this.getTexture(baseTextureUrl),
this.getTexture(overlayTextureUrl)
]);
// Create a render target to compose textures
const width = Math.max(baseTexture.image.width, overlayTexture.image.width);
const height = Math.max(baseTexture.image.height, overlayTexture.image.height);
const renderTarget = new THREE.WebGLRenderTarget(width, height, {
format: THREE.RGBAFormat,
type: THREE.UnsignedByteType,
encoding: THREE.sRGBEncoding
});
// Set up compositor scene
const compositorCamera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.1, 10);
compositorCamera.position.z = 1;
const compositorScene = new THREE.Scene();
// Create base layer
const baseMaterial = new THREE.MeshBasicMaterial({ map: baseTexture });
const basePlane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), baseMaterial);
compositorScene.add(basePlane);
// Create overlay layer with appropriate blend mode
const overlayMaterial = new THREE.ShaderMaterial({
uniforms: {
baseTexture: { value: baseTexture },
overlayTexture: { value: overlayTexture },
opacity: { value: opacity }
},
vertexShader: this._getCompositorVertexShader(),
fragmentShader: this._getCompositorFragmentShader(blendMode),
transparent: true
});
const overlayPlane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), overlayMaterial);
overlayPlane.position.z = 0.01;
compositorScene.add(overlayPlane);
// Render composition to target
const renderer = this._getSharedRenderer();
renderer.setRenderTarget(renderTarget);
renderer.render(compositorScene, compositorCamera);
renderer.setRenderTarget(null);
// Create a new texture from the render target
const compositeTexture = renderTarget.texture.clone();
compositeTexture.encoding = THREE.sRGBEncoding;
compositeTexture.needsUpdate = true;
// Clean up
baseMaterial.dispose();
overlayMaterial.dispose();
renderTarget.dispose();
// Cache and return the result
this.compositeCache.set(cacheKey, compositeTexture);
return compositeTexture;
}
// Apply a material template to a model part
async applyMaterial(mesh, templateId, customProperties = {}) {
const template = this.materialTemplates[templateId];
if (!template) {
console.error(`Material template ${templateId} not found`);
return;
}
// Create a new PBR material
const material = new THREE.MeshStandardMaterial();
// Apply base properties
Object.entries(template.baseProperties).forEach(([key, value]) => {
material[key] = value;
});
// Override with custom properties
const properties = { ...template.baseProperties, ...customProperties };
// Load and apply textures
const texturePromises = [];
for (const [mapName, textureInfo] of Object.entries(template.textureMaps)) {
// Check if we need to create a composite texture
if (textureInfo.composite && customProperties[`${mapName}Overlay`]) {
const baseUrl = properties[mapName] || textureInfo.default;
const overlayUrl = customProperties[`${mapName}Overlay`];
const blendMode = customProperties[`${mapName}BlendMode`] || 'multiply';
const opacity = customProperties[`${mapName}Opacity`] || 1.0;
texturePromises.push(
this.createCompositeTexture(baseUrl, overlayUrl, blendMode, opacity)
.then(texture => {
material[textureInfo.property] = texture;
})
);
} else if (properties[mapName]) {
// Standard texture loading
texturePromises.push(
this.getTexture(properties[mapName])
.then(texture => {
material[textureInfo.property] = texture;
})
);
}
}
// Wait for all textures to load
await Promise.all(texturePromises);
// Apply the material to the mesh
if (mesh.material) {
mesh.material.dispose(); // Clean up old material
}
mesh.material = material;
return material;
}
// Shader code and other helper methods...
}