Spaces:
Running
Running
File size: 8,035 Bytes
c77f6d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | import {Texture,Source} from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {MeshoptDecoder} from 'three/addons/libs/meshopt_decoder.module.js';
import {fetchSceneBytes} from './scene-assets.js?v=20261002a';
const KEY=Uint8Array.from('5c2f9e71d3a84b06e1f7c29a38d05b64a7193ce8f2046db95e81c73a2df6904b'.match(/../g),h=>parseInt(h,16));
export function unpack(bytes){for(let i=0;i<bytes.length;i++)bytes[i]^=KEY[i&31]^(((i>>>5)*131)&255);return bytes;}
let manifestPromise;
function manifest(){return manifestPromise??=fetch(new URL('../data/progressive-scenes.json?v=1',import.meta.url)).then(r=>r.ok?r.json():{}).catch(()=>({}));}
// Two texture jobs across the entire page, separate from the geometry queue.
let running=0;const waiting=[];
function textureJob(work,signal){return new Promise((resolve,reject)=>{waiting.push({work,signal,resolve,reject});pump();});}
function pump(){while(running<2&&waiting.length){const job=waiting.shift();if(job.signal.aborted){job.reject(new DOMException('Aborted','AbortError'));continue;}running++;Promise.resolve().then(job.work).then(job.resolve,job.reject).finally(()=>{running--;pump();});}}
class Materials {
constructor(parser,maxSize){this.parser=parser;this.maxSize=maxSize;this.sources=new Map();this.bundles=new Map();this.failedBundles=new Set();this.active=false;this.disposed=false;this.epoch=0;this.onUpdate=()=>{};}
placeholder(index){
let entry=this.sources.get(index);
if(!entry){const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const ctx=canvas.getContext('2d');ctx.fillStyle='#ffffff';ctx.fillRect(0,0,1,1);const texture=new Texture(canvas);texture.needsUpdate=true;entry={index,texture,state:'pending',refs:new Set([texture])};this.sources.set(index,entry);}
return Promise.resolve(entry.texture.clone());
}
bind(root){
root.traverse(node=>{for(const material of [].concat(node.material||[])){for(const [name,texture] of Object.entries(material)){if(!texture?.isTexture)continue;const entry=[...this.sources.values()].find(e=>e.texture.source===texture.source);if(!entry)continue;entry.refs.add(texture);const colors={map:'#b0b0b0',normalMap:'#8080ff',clearcoatNormalMap:'#8080ff',emissiveMap:'#000000',roughnessMap:'#ffff00',metalnessMap:'#ffff00',transmissionMap:'#000000'};if(colors[name]){const ctx=texture.image.getContext('2d');ctx.fillStyle=colors[name];ctx.fillRect(0,0,1,1);texture.needsUpdate=true;}}}});
}
get status(){const entries=[...this.sources.values()];return {loaded:entries.filter(e=>e.state==='loaded').length,failed:entries.filter(e=>e.state==='failed').length,total:entries.length,active:this.active};}
async imageBytes(entry,signal){
const image=this.parser.json.images[entry.index],part=image.extras?.c4sTexture;
if(part){
if(!this.bundles.has(part.bundle)){const promise=(async()=>{for(let attempt=0;attempt<2;attempt++){try{return unpack(await fetchSceneBytes(new URL(part.bundle,this.parser.options.path),signal,()=>{}));}catch(error){if(signal.aborted||attempt===1)throw error;}}})().catch(error=>{if(signal.aborted)this.bundles.delete(part.bundle);else this.failedBundles.add(part.bundle);throw error;});this.bundles.set(part.bundle,promise);}
const bytes=await this.bundles.get(part.bundle);return new Blob([bytes.subarray(part.offset,part.offset+part.length)],{type:image.mimeType});
}
if(image.bufferView!==undefined)return new Blob([await this.parser.getDependency('bufferView',image.bufferView)],{type:image.mimeType});
const bytes=await fetchSceneBytes(new URL(image.uri,this.parser.options.path),signal,()=>{});return new Blob([bytes],{type:image.mimeType});
}
async load(entry,signal){
let bitmap;
for(let attempt=0;attempt<2;attempt++){
try{const blob=await this.imageBytes(entry,signal);signal.throwIfAborted();bitmap=await createImageBitmap(blob,{colorSpaceConversion:'none',premultiplyAlpha:'none'});break;}
catch(error){if(signal.aborted||attempt===1)throw error;}
}
if(signal.aborted){bitmap.close();signal.throwIfAborted();}
if(this.maxSize&&Math.max(bitmap.width,bitmap.height)>this.maxSize){const scale=this.maxSize/Math.max(bitmap.width,bitmap.height);const small=await createImageBitmap(bitmap,{resizeWidth:Math.max(1,Math.round(bitmap.width*scale)),resizeHeight:Math.max(1,Math.round(bitmap.height*scale)),resizeQuality:'high',colorSpaceConversion:'none',premultiplyAlpha:'none'});bitmap.close();bitmap=small;}
if(signal.aborted||this.disposed){bitmap.close();throw new DOMException('Aborted','AbortError');}
// WebGL2 texture storage cannot grow from a 1px placeholder in place.
// Retain each glTF texture object's sampler/transform, but allocate fresh storage.
const source=new Source(bitmap);for(const texture of entry.refs){texture.dispose();texture.source=source;texture.needsUpdate=true;}entry.state='loaded';this.onUpdate(this.status);
// Yield between uploads so orbiting and the first geometry frame remain responsive.
await new Promise(resolve=>setTimeout(resolve,0));
}
start(onUpdate){
this.onUpdate=onUpdate||this.onUpdate;
if(this.active||this.disposed||![...this.sources.values()].some(e=>e.state==='pending'))return;
this.active=true;const epoch=++this.epoch;this.controller=new AbortController();const signal=this.controller.signal;
const worker=async()=>{while(this.active&&!signal.aborted){const entry=[...this.sources.values()].find(e=>e.state==='pending');if(!entry)break;entry.state='loading';try{await textureJob(()=>this.load(entry,signal),signal);}catch(error){entry.state=signal.aborted?'pending':'failed';if(!signal.aborted){entry.error=error;this.onUpdate(this.status);}}}};
Promise.all([worker(),worker()]).finally(()=>{if(this.epoch===epoch){this.active=false;this.onUpdate(this.status);}});
}
pause(){this.active=false;this.epoch++;this.controller?.abort();}
retry(){for(const name of this.failedBundles)this.bundles.delete(name);this.failedBundles.clear();for(const e of this.sources.values())if(e.state==='failed')e.state='pending';this.start();}
dispose(){this.disposed=true;this.pause();this.bundles.clear();this.sources.clear();this.parser=null;}
}
export async function loadProgressive(url,{signal,onProgress=()=>{},derivative=true,maxTextureSize=512}={}){
const original=new URL(url,document.baseURI),name=original.pathname.split('/').pop();
const entry=derivative?(await manifest())[name]:null;
const geometry=entry?new URL('../'+entry.geometry,import.meta.url):original;
onProgress('downloading',{received:0,total:0});
let bytes;
try{bytes=await fetchSceneBytes(geometry.href,signal,p=>onProgress('downloading',p));}
catch(error){if(!entry||signal.aborted)throw error;bytes=await fetchSceneBytes(original.href,signal,p=>onProgress('downloading',p));}
unpack(bytes);signal.throwIfAborted();onProgress('decoding');let materials;
const loader=new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
loader.register(parser=>{materials=new Materials(parser,maxTextureSize);parser.loadImageSource=index=>materials.placeholder(index);return {name:'C4S_progressive_materials'};});
const gltf=await new Promise((resolve,reject)=>{
let settled=false;
const fail=error=>{if(settled)return;settled=true;clearTimeout(timer);signal.removeEventListener('abort',abort);materials?.dispose();reject(error);};
const abort=()=>fail(new DOMException('Aborted','AbortError'));
const timer=setTimeout(()=>fail(new Error('Scene geometry decoding timed out.')),30000);
signal.addEventListener('abort',abort,{once:true});
loader.parseAsync(bytes.buffer,new URL('.',geometry).href).then(result=>{
if(settled||signal.aborted){result.scene.traverse(n=>{n.geometry?.dispose();for(const m of [].concat(n.material||[]))m.dispose();});materials?.dispose();return;}
settled=true;clearTimeout(timer);signal.removeEventListener('abort',abort);resolve(result);
},fail);
});
materials.bind(gltf.scene);gltf.materials=materials;
return gltf;
}
|