diff --git a/src/runtime-three/light-volume-utils.ts b/src/runtime-three/light-volume-utils.ts new file mode 100644 index 00000000..77c702c4 --- /dev/null +++ b/src/runtime-three/light-volume-utils.ts @@ -0,0 +1,137 @@ +import type { Vec3 } from "../core/vector"; +import type { BoxBrushLightFalloffMode } from "../document/brushes"; + +const MAX_LIGHT_VOLUME_POINT_LIGHTS = 4; +const MIN_SPLIT_HALF_EXTENT = 0.65; +const MIN_LIGHT_DISTANCE = 0.75; + +const POSITION_FACTORS: Record = { + linear: 0.45, + smoothstep: 0.7 +}; + +const DISTANCE_SCALES: Record = { + linear: 1, + smoothstep: 1.1 +}; + +const DISTANCE_PADDING_SCALES: Record = { + linear: 0.5, + smoothstep: 0.35 +}; + +const DECAY_BY_FALLOFF: Record = { + linear: 1.4, + smoothstep: 2 +}; + +type Vec3Axis = keyof Vec3; + +export interface DerivedLightVolumePointLight { + localPosition: Vec3; + intensity: number; + distance: number; + decay: number; +} + +function createVec3(x: number, y: number, z: number): Vec3 { + return { x, y, z }; +} + +function getHalfSize(size: Vec3): Vec3 { + return createVec3(size.x * 0.5, size.y * 0.5, size.z * 0.5); +} + +function getInnerHalfSize(halfSize: Vec3, padding: number): Vec3 { + return createVec3( + Math.max(0, halfSize.x - padding), + Math.max(0, halfSize.y - padding), + Math.max(0, halfSize.z - padding) + ); +} + +function getAxisOffsets( + halfExtent: number, + split: boolean, + positionFactor: number +): number[] { + if (!split || halfExtent <= 0) { + return [0]; + } + + const offset = halfExtent * positionFactor; + return [-offset, offset]; +} + +export function deriveBoxLightVolumePointLights(options: { + size: Vec3; + intensity: number; + padding: number; + falloff: BoxBrushLightFalloffMode; +}): DerivedLightVolumePointLight[] { + if (options.intensity <= 0) { + return []; + } + + const halfSize = getHalfSize(options.size); + const innerHalfSize = getInnerHalfSize( + halfSize, + Math.max(0, options.padding) + ); + const axisOrder = (["x", "y", "z"] as Vec3Axis[]).sort( + (left, right) => innerHalfSize[right] - innerHalfSize[left] + ); + const splitAxes: Record = { + x: false, + y: false, + z: false + }; + let lightCount = 1; + + for (const axis of axisOrder) { + if (lightCount >= MAX_LIGHT_VOLUME_POINT_LIGHTS) { + break; + } + + if (innerHalfSize[axis] < MIN_SPLIT_HALF_EXTENT) { + continue; + } + + splitAxes[axis] = true; + lightCount *= 2; + } + + const positionFactor = POSITION_FACTORS[options.falloff]; + const offsetX = getAxisOffsets(innerHalfSize.x, splitAxes.x, positionFactor); + const offsetY = getAxisOffsets(innerHalfSize.y, splitAxes.y, positionFactor); + const offsetZ = getAxisOffsets(innerHalfSize.z, splitAxes.z, positionFactor); + const perLightIntensity = options.intensity / Math.sqrt(lightCount); + const halfDiagonal = Math.sqrt( + halfSize.x * halfSize.x + + halfSize.y * halfSize.y + + halfSize.z * halfSize.z + ); + const distance = Math.max( + MIN_LIGHT_DISTANCE, + halfDiagonal * DISTANCE_SCALES[options.falloff] - + Math.max(0, options.padding) * + DISTANCE_PADDING_SCALES[options.falloff] + ); + const decay = DECAY_BY_FALLOFF[options.falloff]; + const lights: DerivedLightVolumePointLight[] = []; + + for (const x of offsetX) { + for (const y of offsetY) { + for (const z of offsetZ) { + lights.push({ + localPosition: createVec3(x, y, z), + intensity: perLightIntensity, + distance, + decay + }); + } + } + } + + return lights; +}