Files
martinfencl.eu/locenice2/js/three-renderer.js
T
martin.fencl ba7790daba
continuous-integration/drone/push Build is passing
feat: Add legacy shaders, model I/O, and project panel functionality
- Introduced `legacy-shaders.js` containing vertex and fragment shaders for rendering.
- Implemented `model-io.js` for parsing and encoding GLB files, ensuring compatibility with glTF 2.0.
- Created `project-panel.js` to manage project resources and survey overlays, enhancing user interaction.
- Added `solar.js` for calculating solar positions based on UTC time, latitude, and longitude.
- Developed `three-renderer.js` as a standard backend for Three.js, managing scene rendering and material synchronization.
- Included a new GLB model `dum.glb` for testing and demonstration purposes.
- Established `data_checks.mjs` for validating model data and ensuring integrity of assets.
- Created `prepare_export.py` to facilitate Blender GLB exports, maintaining material and node references.
- Added `vendor_three.py` to download and configure a local version of Three.js for the project.
2026-10-22 01:04:21 +02:00

115 lines
8.2 KiB
JavaScript

/** Standard Three.js backend. UI and catalogue never depend on THREE internals. */
export async function createThreeRenderer({canvas,gltf,binary,bytes,config}) {
let timer;
const modules=Promise.all([import(config.threeModule),import(config.threeLoader)]);
const [T,{GLTFLoader}]=await Promise.race([modules,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Three.js CDN timeout')),config.cdnTimeoutMs||6500);})]).finally(()=>clearTimeout(timer));
const loader=new GLTFLoader();
const documentModel=await loader.parseAsync(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength),'');
const scene=new T.Scene();scene.add(documentModel.scene);
const renderer=new T.WebGLRenderer({canvas,antialias:true,alpha:false,preserveDrawingBuffer:true,powerPreference:'high-performance'});
renderer.outputColorSpace=T.SRGBColorSpace;
renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=1.05;
renderer.shadowMap.enabled=true;renderer.shadowMap.type=T.PCFSoftShadowMap;renderer.shadowMap.autoUpdate=false;
const ambient=new T.AmbientLight(0xffffff,.7);
const skyLight=new T.HemisphereLight(0xf5f8ff,0x67604e,1.7);
const sun=new T.DirectionalLight(0xffffff,3.0);
const fill=new T.HemisphereLight(0xfff5df,0x77716a,0);
scene.add(ambient,skyLight,sun,sun.target,fill);
sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.bias=-.00008;sun.shadow.normalBias=.025;
const persp=new T.PerspectiveCamera(),ortho=new T.OrthographicCamera();
const textures=await Promise.all((gltf.textures||[]).map((_,i)=>documentModel.parser.getDependency('texture',i)));
const images=[];
for (let i=0;i<textures.length;i++) {
const imageSpec=gltf.images[gltf.textures[i].source];
if(imageSpec.bufferView!==undefined){const b=gltf.bufferViews[imageSpec.bufferView];const src=URL.createObjectURL(new Blob([binary.subarray(b.byteOffset||0,(b.byteOffset||0)+b.byteLength)],{type:imageSpec.mimeType}));images[i]={src};}
textures[i].anisotropy=Math.min(8,renderer.capabilities.getMaxAnisotropy());
}
const entries=[],matCopies=new Map(),associations=documentModel.parser.associations;
documentModel.scene.updateMatrixWorld(true);
documentModel.scene.traverse(obj=>{
if(!obj.isMesh)return;
const association=associations.get(obj)||{};
let parent=obj,nodeIndex=association.nodes;
while(nodeIndex===undefined&&parent){const a=associations.get(parent);if(a?.nodes!==undefined)nodeIndex=a.nodes;parent=parent.parent;}
if(nodeIndex===undefined)return;
const node=gltf.nodes[nodeIndex],meshSpec=gltf.meshes[node.mesh];
const primitiveIndex=association.primitives||0;
const primitive=meshSpec.primitives[primitiveIndex]||meshSpec.primitives[0];
const materialIndex=primitive.material;
if(materialIndex===undefined)return;
const spec=gltf.materials[materialIndex],name=node.name||meshSpec.name||obj.name;
// Separate copies prevent a material variant from changing unrelated objects.
let material=matCopies.get(materialIndex);
if(!material){material=obj.material.clone();material.side=T.DoubleSide;matCopies.set(materialIndex,material);}
obj.material=material;
const box=new T.Box3().setFromObject(obj);
const pbr=spec.pbrMetallicRoughness||{};
const desc={object:obj,std:node.extras||{},transparent:spec.alphaMode==='BLEND',material:materialIndex,name,nodeIndex,
count:obj.geometry.index?.count||obj.geometry.attributes.position.count,context:/^(90_|91_|28_|23_)/.test(name),
legacySite:!!node.extras?.legacySite,siteLayer:node.extras?.siteLayer||null,castShadow:node.extras?.castShadow!==false,
bounds:{min:box.min.toArray(),max:box.max.toArray()},glass:(spec.name||'').startsWith('glass'),
interiorLayer:node.extras?.interiorLayer||null,interiorMaterial:!!spec.extras?.interiorMaterial,
interiorStyle:spec.extras?.interiorStyle||0,pbr};
obj.castShadow=desc.castShadow&&!desc.glass&&!desc.transparent;obj.receiveShadow=!desc.transparent;
if(desc.transparent){material.transparent=true;material.depthWrite=false;}
entries.push(desc);
});
const stamps=new Map(),textureCopies=new Map();
function localTexture(index,role,materialIndex,scale) {
const key=materialIndex+':'+role+':'+index+':'+scale.join(',');
if(!textureCopies.has(key)){
const tx=textures[index]?.clone();if(!tx)return null;
tx.colorSpace=role==='map'?T.SRGBColorSpace:T.NoColorSpace;
tx.repeat.set(...scale);tx.needsUpdate=true;textureCopies.set(key,tx);
}
return textureCopies.get(key);
}
function syncMaterials(){
for(const [index,mat] of matCopies){
const spec=gltf.materials[index],p=spec.pbrMetallicRoughness||{};
const stamp=JSON.stringify([p,spec.normalTexture,spec.emissiveFactor,spec.name]);if(stamps.get(index)===stamp)continue;stamps.set(index,stamp);
const rgba=p.baseColorFactor||[.7,.7,.7,1];mat.color.setRGB(...rgba.slice(0,3));
// Legacy exterior factors were sRGB; tagged PBR / interior factors are already linear.
if(!spec.extras?.interiorMaterial&&!spec.extras?.linearColor&&!spec.extras?.hardieRole)mat.color.convertSRGBToLinear();
mat.opacity=rgba[3]??1;mat.roughness=p.roughnessFactor??.8;mat.metalness=p.metallicFactor??0;
const tex=p.baseColorTexture;
mat.map=tex?localTexture(tex.index,'map',index,tex.extensions?.KHR_texture_transform?.scale||[1,1]):null;
mat.normalMap=spec.normalTexture?localTexture(spec.normalTexture.index,'normalMap',index,[1,1]):null;
if(mat.normalScale&&spec.normalTexture)mat.normalScale.setScalar(spec.normalTexture.scale??1);
if(spec.extras?.interiorStyle===1){mat.emissive.copy(mat.color);mat.emissiveIntensity=.75;}
mat.needsUpdate=true;
}
}
const temp=new T.Vector3();let oldVisible='',oldInterior='';
function shadowFit(frame){
const low=[Infinity,Infinity,Infinity],high=[-Infinity,-Infinity,-Infinity];
for(const m of entries){if(!m.object.visible||m.glass)continue;for(let a=0;a<3;a++){low[a]=Math.min(low[a],m.bounds.min[a]);high[a]=Math.max(high[a],m.bounds.max[a]);}}
if(!Number.isFinite(low[0]))return;
if(frame.interior.enabled){low.splice(0,3,-9,-1,-6);high.splice(0,3,11,6,6);}
const center=low.map((v,i)=>(v+high[i])/2);
sun.target.position.fromArray(center);sun.position.fromArray(center).addScaledVector(temp.fromArray(frame.solar.light),120);
sun.target.updateMatrixWorld();sun.updateMatrixWorld();
const c=sun.shadow.camera;c.near=.1;c.far=320;c.position.copy(sun.position);c.up.set(0,1,0);c.lookAt(sun.target.position);c.updateMatrixWorld();
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for(let a=0;a<8;a++){temp.set(a&1?high[0]:low[0],a&2?high[1]:low[1],a&4?high[2]:low[2]).applyMatrix4(c.matrixWorldInverse);minX=Math.min(minX,temp.x);maxX=Math.max(maxX,temp.x);minY=Math.min(minY,temp.y);maxY=Math.max(maxY,temp.y);}
c.left=minX-2;c.right=maxX+2;c.bottom=minY-2;c.top=maxY+2;c.updateProjectionMatrix();renderer.shadowMap.needsUpdate=true;
}
return {backend:'three',label:'Three.js r'+T.REVISION,meshes:entries,images,
resize(w,h,dpr){renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);},
draw(frame){
syncMaterials();
let key='';for(const m of entries){m.object.visible=!!frame.visible(m);key+=m.object.visible?'1':'0';}
if(frame.solar.shadowDirty||key!==oldVisible||oldInterior!==frame.interior.mode){shadowFit(frame);oldVisible=key;oldInterior=frame.interior.mode;}
sun.intensity=Math.max(0,frame.solar.direct)*3.0;sun.color.fromArray(frame.solar.sunColor);sun.castShadow=frame.solar.shadows;
const day=frame.solar.dayFactor;skyLight.intensity=.18+1.5*day;ambient.intensity=.12+.45*day;
fill.intensity=frame.interior.enabled&&frame.interior.fill?1.3:0;
scene.background=new T.Color().setRGB(...frame.solar.sky).convertSRGBToLinear();
const camera=frame.orthographic?ortho:persp;
camera.position.fromArray(frame.eye);camera.up.set(0,1,0);camera.lookAt(temp.fromArray(frame.lookTarget));camera.updateMatrixWorld();
camera.projectionMatrix.fromArray(frame.vp).multiply(camera.matrixWorld);camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
renderer.render(scene,camera);
},
dispose(){renderer.dispose();images.forEach(i=>i?.src&&URL.revokeObjectURL(i.src));textureCopies.forEach(t=>t.dispose());}
};
}