/** * 3D Foundation Project * Copyright 2025 Smithsonian Institution * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { PMREMGenerator, WebGLRenderTarget, Texture, Euler } from "three"; import Component, { IComponentEvent, types } from "@ff/graph/Component"; import CVAssetManager from "./CVAssetManager"; import CVAssetReader from "./CVAssetReader"; import { IEnvironment } from "client/schema/setup"; import CScene from "client/../../libs/ff-scene/source/components/CScene"; import CRenderer from "@ff/scene/components/CRenderer"; import { DEG2RAD } from "three/src/math/MathUtils"; import CVBackground from "./CVBackground"; import CVMeta from "./CVMeta"; import NVNode from "client/nodes/NVNode"; import CVEnvironmentLight from "./lights/CVEnvironmentLight"; import CVModel2, { IModelLoadEvent } from "./CVModel2"; import { TImageQuality, TImageUsage } from "client/schema/meta"; const images = ["studio_small_08_1k.hdr", "capture_tent_mockup-v2-1k.hdr", "spruit_sunrise_1k_HDR.hdr"]; //////////////////////////////////////////////////////////////////////////////// const _euler = new Euler(); export default class CVEnvironment extends Component { static readonly typeName: string = "CVEnvironment"; static readonly text: string = "Environment"; protected static readonly envIns = { imageIndex: types.Integer("Environment.MapIndex", { preset: 0, options: images.map( function(item, index) {return index.toString();}) }), initialize: types.Event("Environment.Init"), intensity: types.Number("Environment.Intensity", {preset:1, min: 0,}), rotation: types.Vector3("Environment.Rotation"), visible: types.Boolean("Environment.Visible", false), enabled: types.Boolean("Environment.Enabled", true) }; ins = this.addInputs(CVEnvironment.envIns); private _target: WebGLRenderTarget = null; private _pmremGenerator :PMREMGenerator = null; private _currentIdx = 0; private _loadRequestId = 0; private _imageOptions: string[] = images.slice(); private _loadingCount = 0; private _hasContent = false; private _isLegacy = false; // flag if scene is legacy (no loaded env light) private _isLegacyRefl = false; // fkag if scene is legacy and has reflective material get settingProperties() { return [ this.ins.intensity, this.ins.rotation, this.ins.imageIndex, this.ins.visible ]; } protected get assetManager() { return this.getMainComponent(CVAssetManager); } protected get assetReader() { return this.getMainComponent(CVAssetReader); } protected get background() { return this.getSystemComponent(CVBackground); } protected get meta() { return this.getComponent(CVMeta); } protected get sceneNode() { return this.getSystemComponent(CScene); } protected get renderer(){ return this.getSystemComponent(CRenderer); } create() { super.create(); this.system.components.on(CVMeta, this.onMetaComponent, this); this.graph.components.on(CVModel2, this.onModelComponent, this); } dispose() { this.graph.components.off(CVModel2, this.onModelComponent, this); this.system.components.off(CVMeta, this.onMetaComponent, this); if(this.sceneNode.scene.environment){ //Ensure scene does not keep a reference to our texture //Because otherwise it would get re-uploaded and possibly leak this.sceneNode.scene.environment = null; } if(this.sceneNode.scene.background) { this.sceneNode.scene.background = null; } if(this._target) { //this._target.texture?.dispose(); //this._target.texture = null; this._target.dispose(); this._target = null; } this._pmremGenerator?.dispose(); this._pmremGenerator = null; super.dispose(); } update() { const ins = this.ins; if(ins.initialize.changed) { this._isLegacy = false; this._isLegacyRefl = false; if(!this.graph.hasComponent(CVEnvironmentLight)) { this.addLightComponent(false); } } if(ins.imageIndex.changed && ((ins.enabled.value && this._hasContent) || ins.visible.value)) { this.loadEnvironmentMap(); } if(ins.intensity.changed) { this.sceneNode.scene.environmentIntensity = ins.intensity.value; ins.visible.value ? this.sceneNode.scene.backgroundIntensity = ins.intensity.value : null; } if(ins.rotation.changed) { const rot = ins.rotation.value; _euler.set(rot[0]*DEG2RAD,rot[1]*DEG2RAD,rot[2]*DEG2RAD); this.renderer.forceRender(); } if(ins.enabled.changed) { if(ins.enabled.value && this._hasContent) { this.loadEnvironmentMap(); } this.sceneNode.scene.environment = ins.enabled.value ? this._target?.texture : null; } if(ins.visible.changed && this._loadingCount == 0) { if(ins.visible.value && this._target === null) { this.loadEnvironmentMap(); } else { this.sceneNode.scene.background = ins.visible.value ? this._target?.texture : null; ins.visible.value && this.sceneNode.scene.background ? (this.sceneNode.scene.background as Texture).needsUpdate = true : null; this.background.ins.visible.setValue(!ins.visible.value); } } // Optimization to dispose when map is not being used at all if(this._target && !ins.enabled.value && !ins.visible.value){ this._target.dispose(); this._target = null; this.sceneNode.scene.environment = null; this.sceneNode.scene.background = null; } return true; } fromData(data: IEnvironment) { const ins = this.ins; if (data.rotation) { ins.rotation.setValue(data.rotation); } if (data.index) { ins.imageIndex.setValue(data.index); } if (data.visible) { ins.visible.setValue(data.visible); } } toData(): IEnvironment { const ins = this.ins; return { index: ins.imageIndex.value, visible: ins.visible.value, rotation: ins.rotation.cloneValue() }; } protected loadEnvironmentMap() { const ins = this.ins; if(ins.imageIndex.value != this._currentIdx || this._target === null) { try { if(this._pmremGenerator === null) { let renderer = this.renderer.views[0].renderer; if(!renderer) throw new Error(`No renderer found : can't generate environment`); this._pmremGenerator = new PMREMGenerator(this.renderer.views[0].renderer); } } catch(e) { console.error("Failed to compile environment map : ", e); } const mapName = this._imageOptions[ins.imageIndex.value]; if(!mapName) { throw new Error("Error loading map name for image index " + ins.imageIndex.value); } const requestId = ++this._loadRequestId; if(images.includes(mapName)) { this._loadingCount++; this.assetReader.getSystemTexture("images/"+mapName).then(texture => { this.updateEnvironmentMap(texture, mapName, requestId); }); } else { this._loadingCount++; this.assetReader.getTexture(mapName).then(texture => { this.updateEnvironmentMap(texture, mapName, requestId); }); } this._currentIdx = ins.imageIndex.value; } } protected updateEnvironmentMap(texture: Texture, name: string, requestId: number) { const ins = this.ins; const mapIdx = this._imageOptions.indexOf(name); if(requestId === this._loadRequestId && mapIdx == ins.imageIndex.value) { const previousTarget = this._target; this._target = this._pmremGenerator.fromEquirectangular(texture); this.sceneNode.scene.environment = ins.enabled.value ? this._target.texture : null; this.sceneNode.scene.background = ins.visible.value ? this._target.texture : null; if(this.sceneNode.scene.environment) { (this.sceneNode.scene.environment as Texture).needsUpdate = true; } if(this.sceneNode.scene.background) { (this.sceneNode.scene.background as Texture).needsUpdate = true; this.background.ins.visible.setValue(false); } this.sceneNode.scene.environmentRotation = _euler; this.sceneNode.scene.backgroundRotation = _euler; this.renderer.forceRender(); if(previousTarget && previousTarget !== this._target) { previousTarget.dispose(); } } texture.dispose(); texture = null; this._loadingCount--; this.ins.visible.set(); } protected onMetaComponent(event: IComponentEvent) { const meta = event.object; if (event.add) { meta.once("load", () => { const images = meta.images.dictionary; Object.keys(images).forEach(key => { const image = images[key]; if (image.usage && image.usage === "Environment") { this.addImage(image.uri); } }); }); } } addImage(image_uri: string) { if (this._imageOptions.indexOf(image_uri) == -1) { const url = this.assetManager.getAssetUrl(image_uri); this.readHdrDimensions(url).then(({ width, height, size }) => { this.meta?.images.insert({ uri: image_uri, usage: "Environment" as TImageUsage, quality: "HDR" as TImageQuality, byteSize: size, width: width, height: height, }, image_uri); }); this._imageOptions.push(image_uri); this._updateImageIndex(); } else { console.debug(image_uri + " already exists, skipping.") } } async readHdrDimensions(image_url: string): Promise<{ width: number; height: number, size: number } | undefined> { const HEADER_LENGTH = 8192; const FALLBACK_DIMENSIONS = { width: 1024, height: 1024, size: 4096 }; let size = FALLBACK_DIMENSIONS.size; if(!image_url.startsWith("blob:")) { try { const headResponse = await fetch(image_url, { method: "HEAD" }); if (headResponse.ok) { size = parseInt(headResponse.headers.get('Content-Length') || '0'); } } catch(e) { console.error('Failed to get file size via HEAD request for: ', image_url, e); } } const response = await fetch(image_url, { headers: { Range: `bytes=0-${HEADER_LENGTH - 1}` }, }); if (response.ok) { const bytes = await response.arrayBuffer(); const text = new TextDecoder('latin1').decode(bytes); const match = text.match(/^([-+])([xyXY])\s+(\d+)\s+([-+])([xyXY])\s+(\d+)/m); if (match) { const first = parseInt(match[3], 10); const second = parseInt(match[6], 10); const dims = match[2].toLowerCase() === 'x' ? { width: first, height: second } : { width: second, height: first }; return { ...dims, size }; } } console.warn('Failed to read image dimensions from header in: ', image_url) return FALLBACK_DIMENSIONS; } deleteImage(image_uri: string) { const index = this._imageOptions.indexOf(image_uri); if (index == -1) { console.error("Trying to remove environment image that is not registered in the options list: " + image_uri) } else { if (index == this.ins.imageIndex.value) { this.ins.imageIndex.setOption("0"); this.ins.visible.set(false); } this._imageOptions.splice(index, 1); this._updateImageIndex(); this.meta?.images.remove(image_uri); } } protected _updateImageIndex() { this.ins.imageIndex.setOptions(this._imageOptions.map( function(item, index) {return index.toString();})); } protected addLightComponent(enabled: boolean) { const lightNode = this.graph.findNodeByName("Lights") as NVNode; const childNode = lightNode.graph.createCustomNode(lightNode as NVNode) as NVNode; const envLight = childNode.transform.createComponent(CVEnvironmentLight); envLight.ins.enabled.setValue(enabled); lightNode.transform.addChild(childNode.transform); this._isLegacy = true; } protected onModelComponent(event: IComponentEvent) { const component = event.object; if (event.add) { component.on("model-load", () => this.legacyCheck(component), this); // Hack to prevent env map from loading when not needed this._hasContent = true; this.ins.enabled.set(); } else if (event.remove) { component.off("model-load", () => this.legacyCheck(component), this); } } protected legacyCheck(model: CVModel2) { if(!this._isLegacy || this._isLegacyRefl) { return; } // For backwards compatibility, always enable env light if a roughness or metalness map is present model.object3D.traverse(object => { const material = object["material"]; if(material && !this._isLegacyRefl && (material.roughnessMap || material.metalnessMap)) { // Hack to make sure UI updates appropriately const envLight = this.graph.getComponent(CVEnvironmentLight); envLight.transform.dispose(); this.addLightComponent(true); this._isLegacyRefl = true; } }); } }