215 lines
10 KiB
TypeScript
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;
|
|
}
|