Code4Scene / assets /scene-assets.js
KoeYe's picture
Render geometry before progressive textures and self-host Three.js
c77f6d9
Raw History Blame Contribute Delete
4.51 kB
// A bounded, reference-counted queue. A shared scene has one job regardless of how many cards display it.
export class AssetQueue {
constructor({load,dispose,maxConcurrent=2,maxIdle=1,maxBytes=128*1024*1024,onChange=()=>{}}){Object.assign(this,{load,dispose,maxConcurrent,maxIdle,maxBytes,onChange});this.entries=new Map();this.running=0;this.peakRunning=0;}
retain(key,listener,priority=()=>0){
let e=this.entries.get(key);
if(!e||e.state==='error'||e.state==='cancelled'){
e={key,users:new Set(),state:'queued',asset:null,controller:new AbortController(),last:Date.now(),bytes:0};this.entries.set(key,e);
}
const user={listener,priority};e.users.add(user);e.last=Date.now();queueMicrotask(()=>{if(e.users.has(user))listener(e);this.pump();});this.changed();
let released=false;
return {entry:e,release:()=>{if(released)return;released=true;e.users.delete(user);e.last=Date.now();if(!e.users.size&&e.asset)e.asset.materials?.pause();if(!e.users.size&&!e.asset){e.state='cancelled';e.controller.abort();if(this.entries.get(key)===e)this.entries.delete(key);}this.evict();this.pump();this.changed();}};
}
notify(e,state,detail={}){if(e.controller.signal.aborted)return;Object.assign(e,detail,{state});for(const u of e.users)u.listener(e);this.changed();}
pump(){
while(this.running<this.maxConcurrent){const queued=[...this.entries.values()].filter(e=>e.state==='queued'&&e.users.size).sort((a,b)=>Math.min(...[...a.users].map(u=>u.priority()))-Math.min(...[...b.users].map(u=>u.priority())));if(!queued.length)break;const e=queued[0];e.state='loading';this.running++;this.peakRunning=Math.max(this.peakRunning,this.running);this.run(e);}
}
async run(e){
try{
let asset;
for(let attempt=0;attempt<2;attempt++){
try{asset=await this.load(e.key,e.controller.signal,(state,detail)=>this.notify(e,state,detail));break;}
catch(error){if(e.controller.signal.aborted)throw error;if(attempt===1)throw error;this.notify(e,'retrying');await new Promise(r=>setTimeout(r,800));}
}
if(e.controller.signal.aborted||!e.users.size){this.dispose(asset);return;}
e.asset=asset;e.bytes=asset.bytes||0;this.notify(e,'ready');
}catch(error){if(!e.controller.signal.aborted)this.notify(e,'error',{error});}
finally{this.running--;this.evict();this.pump();this.changed();}
}
evict(){
const idle=[...this.entries.values()].filter(e=>!e.users.size&&e.asset).sort((a,b)=>a.last-b.last);let total=[...this.entries.values()].reduce((n,e)=>n+e.bytes,0);
while(idle.length&&(idle.length>this.maxIdle||total>this.maxBytes)){const e=idle.shift();total-=e.bytes;this.dispose(e.asset);e.asset=null;if(this.entries.get(e.key)===e)this.entries.delete(e.key);}
}
changed(){this.onChange({running:this.running,peak:this.peakRunning,queued:[...this.entries.values()].filter(e=>e.state==='queued').length,cached:[...this.entries.values()].filter(e=>e.asset).length,bytes:[...this.entries.values()].reduce((n,e)=>n+e.bytes,0)});}
}
// A stalled body must not leave a loading card forever. Idle and total limits cover headers and streamed bytes.
export async function fetchSceneBytes(url,signal,progress,{idleMs=20000,totalMs=90000,fetcher=fetch}={}){
const abort=new AbortController();let idle,total,reason='';
const stop=()=>abort.abort();signal.addEventListener('abort',stop,{once:true});if(signal.aborted)stop();
const reset=()=>{clearTimeout(idle);idle=setTimeout(()=>{reason='The scene download stopped responding.';abort.abort();},idleMs);};
reset();total=setTimeout(()=>{reason='The scene download took too long.';abort.abort();},totalMs);
try{
const response=await fetcher(url,{signal:abort.signal});if(!response.ok)throw new Error('Scene request failed (HTTP '+response.status+').');
const size=Number(response.headers.get('content-length'))||0,reader=response.body.getReader(),chunks=[];let received=0,last=0;
for(;;){const {done,value}=await reader.read();if(done)break;reset();chunks.push(value);received+=value.length;if(Date.now()-last>120){progress({received,total:size});last=Date.now();}}
if(!received||size&&received!==size)throw new Error('The scene download was incomplete.');
progress({received,total:size});const bytes=new Uint8Array(received);let at=0;for(const c of chunks){bytes.set(c,at);at+=c.length;}return bytes;
}catch(error){if(reason)throw new Error(reason);throw error;}
finally{clearTimeout(idle);clearTimeout(total);signal.removeEventListener('abort',stop);}
}