UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

317 lines (314 loc) 14.3 kB
import { Vec2 } from '../../core/math/vec2.js'; import { Vec3 } from '../../core/math/vec3.js'; import { BoundingBox } from '../../core/shape/bounding-box.js'; import { PIXELFORMAT_R8U } from '../../platform/graphics/constants.js'; import { TextureUtils } from '../../platform/graphics/texture-utils.js'; import { MASK_AFFECT_DYNAMIC, MASK_AFFECT_LIGHTMAPPED, LIGHTTYPE_SPOT, LIGHTTYPE_DIRECTIONAL } from '../constants.js'; import { LightsBuffer } from './lights-buffer.js'; import { Debug } from '../../core/debug.js'; /** * @import { Texture } from '../../platform/graphics/texture.js' */ const tmpSize = new Vec2(); const tempVec3 = new Vec3(); const tempMin3 = new Vec3(); const tempMax3 = new Vec3(); const tempBox = new BoundingBox(); const maxTextureSize = 4096; // maximum texture size allowed to work on all devices // helper class to store properties of a light used by clustering class ClusterLight { constructor(){ // the light itself this.light = null; // bounding box this.min = new Vec3(); this.max = new Vec3(); } } // Main class implementing clustered lighting. Internally it organizes the omni / spot lights placement in world space 3d cell structure, // and also uses LightsBuffer class to store light properties in textures class WorldClusters { set maxCellLightCount(count) { if (count !== this._maxCellLightCount) { this._maxCellLightCount = count; this._cellsDirty = true; } } get maxCellLightCount() { return this._maxCellLightCount; } set cells(value) { // make sure we have whole numbers tempVec3.copy(value).floor(); if (!this._cells.equals(tempVec3)) { this._cells.copy(tempVec3); this._cellsLimit.copy(tempVec3).sub(Vec3.ONE); this._cellsDirty = true; } } get cells() { return this._cells; } destroy() { this.lightsBuffer.destroy(); this.releaseClusterTexture(); } releaseClusterTexture() { if (this.clusterTexture) { this.clusterTexture.destroy(); this.clusterTexture = null; } } registerUniforms(device) { this._numClusteredLightsId = device.scope.resolve('numClusteredLights'); this._clusterMaxCellsId = device.scope.resolve('clusterMaxCells'); this._clusterWorldTextureId = device.scope.resolve('clusterWorldTexture'); this._clusterBoundsMinId = device.scope.resolve('clusterBoundsMin'); this._clusterBoundsMinData = new Float32Array(3); this._clusterBoundsDeltaId = device.scope.resolve('clusterBoundsDelta'); this._clusterBoundsDeltaData = new Float32Array(3); this._clusterCellsCountByBoundsSizeId = device.scope.resolve('clusterCellsCountByBoundsSize'); this._clusterCellsCountByBoundsSizeData = new Float32Array(3); this._clusterCellsDotId = device.scope.resolve('clusterCellsDot'); this._clusterCellsDotData = new Int32Array(3); // number of cells in each direction (ivec3) this._clusterCellsMaxId = device.scope.resolve('clusterCellsMax'); this._clusterCellsMaxData = new Int32Array(3); // width of the cluster texture this._clusterTextureWidthId = device.scope.resolve('clusterTextureWidth'); } // updates itself based on parameters stored in the scene updateParams(lightingParams) { if (lightingParams) { this.cells = lightingParams.cells; this.maxCellLightCount = lightingParams.maxLightsPerCell; this.lightsBuffer.cookiesEnabled = lightingParams.cookiesEnabled; this.lightsBuffer.shadowsEnabled = lightingParams.shadowsEnabled; this.lightsBuffer.areaLightsEnabled = lightingParams.areaLightsEnabled; } } updateCells() { if (this._cellsDirty) { this._cellsDirty = false; const cx = this._cells.x; const cy = this._cells.y; const cz = this._cells.z; // storing 1 light per pixel const numCells = cx * cy * cz; const totalPixels = this.maxCellLightCount * numCells; // cluster texture size - roughly square that fits all cells. The width is multiply of numPixels to simplify shader math const { x: width, y: height } = TextureUtils.calcTextureSize(totalPixels, tmpSize, this.maxCellLightCount); // if the texture is allowed size Debug.assert(width <= maxTextureSize && height <= maxTextureSize, 'Clustered lights parameters cause the texture size to be over the limit, please adjust them.'); // maximum range of cells this._clusterCellsMaxData[0] = cx; this._clusterCellsMaxData[1] = cy; this._clusterCellsMaxData[2] = cz; // vector to allow single dot product to convert from world coordinates to cluster index this._clusterCellsDotData[0] = this.maxCellLightCount; this._clusterCellsDotData[1] = cx * cz * this.maxCellLightCount; this._clusterCellsDotData[2] = cx * this.maxCellLightCount; // cluster data and number of lights per cell this.clusters = new Uint8ClampedArray(totalPixels); this.counts = new Int32Array(numCells); this.releaseClusterTexture(); this.clusterTexture = this.lightsBuffer.createTexture(this.device, width, height, PIXELFORMAT_R8U, 'ClusterTexture'); } } uploadTextures() { this.clusterTexture.lock().set(this.clusters); this.clusterTexture.unlock(); this.lightsBuffer.uploadTextures(); } updateUniforms() { // number of clustered lights (index 0 is reserved for 'no light') this._numClusteredLightsId.setValue(this._usedLights.length); this.lightsBuffer.updateUniforms(); // texture this._clusterWorldTextureId.setValue(this.clusterTexture); // uniform values this._clusterMaxCellsId.setValue(this.maxCellLightCount); const boundsDelta = this.boundsDelta; this._clusterCellsCountByBoundsSizeData[0] = this._cells.x / boundsDelta.x; this._clusterCellsCountByBoundsSizeData[1] = this._cells.y / boundsDelta.y; this._clusterCellsCountByBoundsSizeData[2] = this._cells.z / boundsDelta.z; this._clusterCellsCountByBoundsSizeId.setValue(this._clusterCellsCountByBoundsSizeData); this._clusterBoundsMinData[0] = this.boundsMin.x; this._clusterBoundsMinData[1] = this.boundsMin.y; this._clusterBoundsMinData[2] = this.boundsMin.z; this._clusterBoundsDeltaData[0] = boundsDelta.x; this._clusterBoundsDeltaData[1] = boundsDelta.y; this._clusterBoundsDeltaData[2] = boundsDelta.z; // assign values this._clusterBoundsMinId.setValue(this._clusterBoundsMinData); this._clusterBoundsDeltaId.setValue(this._clusterBoundsDeltaData); this._clusterCellsDotId.setValue(this._clusterCellsDotData); this._clusterCellsMaxId.setValue(this._clusterCellsMaxData); this._clusterTextureWidthId.setValue(this.clusterTexture.width); } // evaluates min and max coordinates of AABB of the light in the cell space evalLightCellMinMax(clusteredLight, min, max) { // min point of AABB in cell space min.copy(clusteredLight.min); min.sub(this.boundsMin); min.div(this.boundsDelta); min.mul2(min, this.cells); min.floor(); // max point of AABB in cell space max.copy(clusteredLight.max); max.sub(this.boundsMin); max.div(this.boundsDelta); max.mul2(max, this.cells); max.ceil(); // clamp to limits min.max(Vec3.ZERO); max.min(this._cellsLimit); } collectLights(lights) { const maxLights = this.lightsBuffer.maxLights; // skip index 0 as that is used for unused light const usedLights = this._usedLights; let lightIndex = 1; lights.forEach((light)=>{ const runtimeLight = !!(light.mask & (MASK_AFFECT_DYNAMIC | MASK_AFFECT_LIGHTMAPPED)); const zeroAngleSpotlight = light.type === LIGHTTYPE_SPOT && light._outerConeAngle === 0; if (light.enabled && light.type !== LIGHTTYPE_DIRECTIONAL && light.visibleThisFrame && light.intensity > 0 && runtimeLight && !zeroAngleSpotlight) { // within light limit if (lightIndex < maxLights) { // reuse allocated spot let clusteredLight; if (lightIndex < usedLights.length) { clusteredLight = usedLights[lightIndex]; } else { // allocate new spot clusteredLight = new ClusterLight(); usedLights.push(clusteredLight); } // store light properties clusteredLight.light = light; light.getBoundingBox(tempBox); clusteredLight.min.copy(tempBox.getMin()); clusteredLight.max.copy(tempBox.getMax()); lightIndex++; } else { Debug.warnOnce(`Clustered lighting: more than ${maxLights - 1} lights in the frame, ignoring some.`); } } }); usedLights.length = lightIndex; } // evaluate the area all lights cover evaluateBounds() { const usedLights = this._usedLights; // bounds of the area the lights cover const min = this.boundsMin; const max = this.boundsMax; // if at least one light (index 0 is null, so ignore that one) if (usedLights.length > 1) { // AABB of the first light min.copy(usedLights[1].min); max.copy(usedLights[1].max); for(let i = 2; i < usedLights.length; i++){ // expand by AABB of this light min.min(usedLights[i].min); max.max(usedLights[i].max); } } else { // any small volume if no lights min.set(0, 0, 0); max.set(1, 1, 1); } // bounds range this.boundsDelta.sub2(max, min); this.lightsBuffer.setBounds(min, this.boundsDelta); } updateClusters(lightingParams) { // clear clusters this.counts.fill(0); this.clusters.fill(0); this.lightsBuffer.areaLightsEnabled = lightingParams ? lightingParams.areaLightsEnabled : false; // local accessors const divX = this._cells.x; const divZ = this._cells.z; const counts = this.counts; const limit = this._maxCellLightCount; const clusters = this.clusters; const pixelsPerCellCount = this.maxCellLightCount; let tooManyLights = false; // started from index 1, zero is "no-light" index const usedLights = this._usedLights; for(let i = 1; i < usedLights.length; i++){ const clusteredLight = usedLights[i]; const light = clusteredLight.light; // add light data into textures this.lightsBuffer.addLightData(light, i); // light's bounds in cell space this.evalLightCellMinMax(clusteredLight, tempMin3, tempMax3); const xStart = tempMin3.x; const xEnd = tempMax3.x; const yStart = tempMin3.y; const yEnd = tempMax3.y; const zStart = tempMin3.z; const zEnd = tempMax3.z; // add the light to the cells for(let x = xStart; x <= xEnd; x++){ for(let z = zStart; z <= zEnd; z++){ for(let y = yStart; y <= yEnd; y++){ const clusterIndex = x + divX * (z + y * divZ); const count = counts[clusterIndex]; if (count < limit) { clusters[pixelsPerCellCount * clusterIndex + count] = i; counts[clusterIndex] = count + 1; } else { tooManyLights = true; } } } } } if (tooManyLights) { const reportLimit = 5; if (this.reportCount < reportLimit) { console.warn(`Too many lights in light cluster ${this.name}, please adjust parameters.${this.reportCount === reportLimit - 1 ? ' Giving up on reporting it.' : ''}`); this.reportCount++; } } } // internal update of the cluster data, executes once per frame update(lights, lightingParams = null) { this.updateParams(lightingParams); this.updateCells(); this.collectLights(lights); this.evaluateBounds(); this.updateClusters(lightingParams); this.uploadTextures(); } // called on already updated clusters, activates for rendering by setting up uniforms / textures on the device activate() { this.updateUniforms(); } constructor(device){ this.device = device; this.name = 'Untitled'; // number of times a warning was reported this.reportCount = 0; // bounds of all light volumes (volume covered by the clusters) this.boundsMin = new Vec3(); this.boundsMax = new Vec3(); this.boundsDelta = new Vec3(); // number of cells along 3 axes this._cells = new Vec3(1, 1, 1); // number of cells this._cellsLimit = new Vec3(); // number of cells minus one this.cells = this._cells; // number of lights each cell can store this.maxCellLightCount = 4; // internal list of lights (of type ClusterLight) this._usedLights = []; // light 0 is always reserved for 'no light' index this._usedLights.push(new ClusterLight()); // allocate textures to store lights this.lightsBuffer = new LightsBuffer(device); // register shader uniforms this.registerUniforms(device); } } export { WorldClusters };