Files
workinf_Blender_Wasm/web/app/src/three-adapter/pbr.ts
mes123456 0fe8d2bb56
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

215 lines
10 KiB
TypeScript

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;
}