import { ACESFilmicToneMapping, Color, DirectionalLight, DoubleSide, MeshPhysicalMaterial, Object3D, PCFShadowMap, PerspectiveCamera, PointLight, RectAreaLight, SRGBColorSpace, SpotLight, Vector3, type Light, type WebGLRenderer, } from "../vendor/three/three.module.js"; import type { CameraIR, LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir"; import { compileMaterialGraph, type ShaderCompileContext, type ShaderCompileReport } from "../../../protocol/shader-compiler"; import { PBR_RENDER_BUDGETS } from "../../../protocol/render-budget"; export const PBR_PROFILE = "physical-v1"; export const PBR_TONE_MAPPING = "aces"; export const PBR_SHADOW_PROFILE = "pcf-1024"; export const PBR_SHADOW_MAP_DIMENSION = PBR_RENDER_BUDGETS.THREE_WEBGL2.shadowMapDimension; function clamp(value: number | undefined, minimum: number, maximum: number, fallback: number): number { return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value as number)) : fallback; } export function configurePBRRenderer(renderer: WebGLRenderer, exposure = 0): void { renderer.outputColorSpace = SRGBColorSpace; renderer.toneMapping = ACESFilmicToneMapping; renderer.toneMappingExposure = 2 ** clamp(exposure, -8, 8, 0); renderer.shadowMap.enabled = true; renderer.shadowMap.type = PCFShadowMap; } export function configurePBRCamera(camera: PerspectiveCamera, definition: CameraIR): void { const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm; const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI; camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov; camera.near = Math.max(0.0001, definition.near); camera.far = Math.max(camera.near + 0.001, definition.far); camera.filmGauge = sensor; camera.filmOffset = definition.shift[0] * sensor; camera.updateProjectionMatrix(); } export function createPBRMaterial(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): MeshPhysicalMaterial { const compileReport = definition?.nodes?.length ? compileMaterialGraph(definition, shaderContext) : undefined; const compiled = compileReport?.status === "COMPILED" ? compileReport.material : undefined; const baseColor = compiled?.baseColor ?? definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]); const emission = compiled?.emissionColor ?? definition?.emissionColor ?? [0, 0, 0, 1]; const alpha = clamp(compiled?.alpha ?? definition?.alpha ?? baseColor[3], 0, 1, 1); const transmission = clamp(compiled?.transmissionWeight ?? definition?.transmissionWeight, 0, 1, 0); const material = new MeshPhysicalMaterial({ color: new Color().setRGB(baseColor[0], baseColor[1], baseColor[2]), roughness: clamp(compiled?.roughness ?? definition?.roughness, 0, 1, 0.45), metalness: clamp(compiled?.metallic ?? definition?.metallic, 0, 1, 0.05), ior: clamp(compiled?.ior ?? definition?.ior, 1, 2.333, 1.45), // Blender's neutral Specular IOR Level is 0.5; Three's neutral multiplier is 1.0. specularIntensity: clamp((compiled?.specularIORLevel ?? definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1), clearcoat: clamp(compiled?.coatWeight ?? definition?.coatWeight, 0, 1, 0), clearcoatRoughness: clamp(compiled?.coatRoughness ?? definition?.coatRoughness, 0, 1, 0.03), transmission, emissive: new Color().setRGB(emission[0], emission[1], emission[2]), emissiveIntensity: clamp(compiled?.emissionStrength ?? definition?.emissionStrength, 0, 1_000_000, 1), opacity: alpha, transparent: alpha < 0.999, depthWrite: alpha >= 0.999, vertexColors: true, side: DoubleSide, }); material.userData.baseEmissive = material.emissive.getHex(); material.userData.baseEmissiveIntensity = material.emissiveIntensity; material.userData.pbrProfile = PBR_PROFILE; if (compileReport) { material.userData.shaderCompile = compileReport as ShaderCompileReport; material.userData.shaderCompileTextures = compileReport.status === "COMPILED" ? compileReport.textureBindings : []; } return material; } export interface PBRMaterialPipelineUpdate { material: MeshPhysicalMaterial; report?: ShaderCompileReport; replaced: boolean; } /** Keeps the last compiled material alive when a later graph fails closed. */ export class PBRMaterialPipeline { private current: MeshPhysicalMaterial | null = null; get material(): MeshPhysicalMaterial | null { return this.current; } update(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): PBRMaterialPipelineUpdate { const candidate = createPBRMaterial(definition, active, shaderContext); const report = candidate.userData.shaderCompile as ShaderCompileReport | undefined; if (report?.status === "BLOCKED" && this.current) { this.current.userData.shaderCompileFailure = report; candidate.dispose(); return { material: this.current, report, replaced: false }; } const previous = this.current; this.current = candidate; previous?.dispose(); return { material: candidate, report, replaced: previous !== null }; } dispose(): void { this.current?.dispose(); this.current = null; } } export function setPBRMaterialSelected(material: MeshPhysicalMaterial, selected: boolean): void { const baseEmissive = typeof material.userData.baseEmissive === "number" ? material.userData.baseEmissive : 0; const baseIntensity = typeof material.userData.baseEmissiveIntensity === "number" ? material.userData.baseEmissiveIntensity : 1; material.emissive.set(selected ? 0x4a1f08 : baseEmissive); material.emissiveIntensity = selected ? Math.max(0.65, baseIntensity) : baseIntensity; } export function blenderLightIntensity(definition: LightIR): number { return Math.max(0, definition.energy) * 2 ** clamp(definition.exposure, -20, 20, 0) / 10; } function blackbodySrgb(temperature: number): [number, number, number] { const value = clamp(temperature, 800, 20_000, 6500) / 100; const red = value <= 66 ? 255 : 329.698727446 * (value - 60) ** -0.1332047592; const green = value <= 66 ? 99.4708025861 * Math.log(value) - 161.1195681661 : 288.1221695283 * (value - 60) ** -0.0755148492; const blue = value >= 66 ? 255 : value <= 19 ? 0 : 138.5177312231 * Math.log(value - 10) - 305.0447927307; return [red, green, blue].map((component) => clamp(component / 255, 0, 1, 0)) as [number, number, number]; } function srgbToLinear(value: number): number { return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; } /** Returns a bounded linear-RGB light color with Blender's 6500 K default treated as neutral. */ export function blenderLightColor(definition: LightIR): [number, number, number] { if (!definition.useTemperature) return [...definition.color]; const neutral = blackbodySrgb(6500); const blackbody = blackbodySrgb(definition.temperature ?? 6500).map((component, index) => component / neutral[index]); const peak = Math.max(1, ...blackbody); const linear = blackbody.map((component) => srgbToLinear(component / peak)); return definition.color.map((component, index) => clamp(component, 0, 1, 0) * linear[index]) as [number, number, number]; } export function createPBRLight(definition: LightIR): Light { const color = new Color().setRGB(...blenderLightColor(definition)); const intensity = blenderLightIntensity(definition); const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) : definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) : definition.lightType === 4 ? new RectAreaLight(color, intensity, definition.areaSize, definition.areaSizeY) : new PointLight(color, intensity, 0, 2); light.userData.blenderCastsShadowDefinition = definition.castsShadow ?? true; light.userData.blenderExposure = definition.exposure ?? 0; light.userData.blenderTemperature = definition.temperature ?? 6500; light.userData.blenderUsesTemperature = definition.useTemperature ?? false; return light; } export function configurePBRLight( light: Light, node: SceneNodeIR, parent: Object3D, options: { shadowEnabled?: boolean; shadowMapDimension?: number } = {}, ): void { const [x, y, z] = node.transform.translation; light.position.set(x, z, -y); light.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]); if (light instanceof PointLight) { light.distance = 0; light.decay = 2; } light.userData.blenderCastsShadow = definitionCastsShadow(light); const shadowEnabled = options.shadowEnabled ?? light.userData.blenderCastsShadow; light.userData.pbrShadowBudgetBlocked = light.userData.blenderCastsShadow && !shadowEnabled; if ((light instanceof DirectionalLight || light instanceof SpotLight) && shadowEnabled) { const target = new Object3D(); const forward = new Vector3(0, -1, 0).applyEuler(light.rotation); target.position.copy(light.position).add(forward); light.target = target; light.castShadow = true; light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION); light.shadow.bias = -0.0005; light.shadow.normalBias = 0.03; light.shadow.camera.near = 0.05; light.shadow.camera.far = 100; if (light instanceof DirectionalLight) { light.shadow.camera.left = -20; light.shadow.camera.right = 20; light.shadow.camera.top = 20; light.shadow.camera.bottom = -20; } parent.add(target); } else if (light instanceof PointLight && shadowEnabled) { light.castShadow = true; light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION); light.shadow.bias = -0.0005; light.shadow.normalBias = 0.03; light.shadow.camera.near = 0.05; light.shadow.camera.far = 100; } } function definitionCastsShadow(light: Light): boolean { return typeof light.userData.blenderCastsShadowDefinition === "boolean" ? light.userData.blenderCastsShadowDefinition : true; }