diff --git a/src/engine/camera/camera.game.ts b/src/engine/camera/camera.game.ts index e06e03ae8..aad95a2b6 100644 --- a/src/engine/camera/camera.game.ts +++ b/src/engine/camera/camera.game.ts @@ -84,7 +84,7 @@ export function createRemotePerspectiveCamera(ctx: GameState, props?: Perspectiv const cameraBufferView = createObjectBufferView(perspectiveCameraSchema, ArrayBuffer); cameraBufferView.layers[0] = props?.layers === undefined ? 1 : props.layers; - cameraBufferView.zfar[0] = props?.zfar || 2000; + cameraBufferView.zfar[0] = props?.zfar || 500; cameraBufferView.znear[0] = props?.znear === undefined ? 0.1 : props.znear; cameraBufferView.aspectRatio[0] = props?.aspectRatio || 0; // 0 for automatic aspect ratio defined by canvas cameraBufferView.yfov[0] = props?.yfov === undefined ? glMatrix.toRadian(50) : props.yfov; diff --git a/src/engine/camera/camera.render.ts b/src/engine/camera/camera.render.ts index 81599a2bb..30a934b9f 100644 --- a/src/engine/camera/camera.render.ts +++ b/src/engine/camera/camera.render.ts @@ -1,8 +1,10 @@ import { OrthographicCamera, PerspectiveCamera, Scene, MathUtils } from "three"; import { getReadObjectBufferView, ReadObjectTripleBufferView } from "../allocator/ObjectBufferView"; +import { getModule } from "../module/module.common"; import { RendererNodeTripleBuffer } from "../node/node.common"; import { LocalNode, updateTransformFromNode } from "../node/node.render"; +import { RendererModule } from "../renderer/renderer.render"; import { RenderThreadState } from "../renderer/renderer.render"; import { ResourceId } from "../resource/resource.common"; import { getLocalResource } from "../resource/resource.render"; @@ -58,6 +60,7 @@ export function updateNodeCamera( node: LocalNode, nodeReadView: ReadObjectTripleBufferView ) { + const rendererModule = getModule(ctx, RendererModule); const currentCameraResourceId = node.camera?.resourceId || 0; const nextCameraResourceId = nodeReadView.camera[0]; @@ -101,6 +104,8 @@ export function updateNodeCamera( // Renderer will update aspect based on the viewport if the aspectRatio is set to 0 if (cameraView.aspectRatio[0]) { perspectiveCamera.aspect = cameraView.aspectRatio[0]; + } else { + perspectiveCamera.aspect = rendererModule.canvasWidth / rendererModule.canvasHeight; } if (cameraView.projectionMatrixNeedsUpdate[0]) { diff --git a/src/engine/light/CSMDirectionalLight.ts b/src/engine/light/CSMDirectionalLight.ts new file mode 100644 index 000000000..24605519d --- /dev/null +++ b/src/engine/light/CSMDirectionalLight.ts @@ -0,0 +1,242 @@ +import { vec3 } from "gl-matrix"; +import { + Box3, + DirectionalLight, + Material, + MathUtils, + Matrix4, + Object3D, + PerspectiveCamera, + Vector2, + Vector3, +} from "three"; + +import CSMFrustum from "./CSMFrustum"; +import { CSMHelper } from "./CSMHelper"; + +const DEBUG = false; + +export const NUM_CSM_CASCADES = 1; +const SHADOW_NEAR = 1; +const SHADOW_FAR = 200; +export const MAX_SHADOW_DISTANCE = 100; +const SHADOW_MAP_SIZE = 2048; +const SHADOW_BIAS = 0; +const LIGHT_MARGIN = 50; + +const logarithmicSplits: number[] = []; +const uniformSplits: number[] = []; +const cameraToLightMatrix = new Matrix4(); +const lightSpaceFrustum = new CSMFrustum(); +const center = new Vector3(); +const bbox = new Box3(); + +export class CSMDirectionalLight extends Object3D { + private color: vec3 = [1, 1, 1]; + private intensity = 1; + private _castShadow = false; + private camera?: PerspectiveCamera; + private splits: number[] = []; + private helper?: CSMHelper; + + public readonly lights: DirectionalLight[] = []; + public readonly mainFrustum: CSMFrustum; + public readonly frustums: CSMFrustum[] = []; + + public direction: Vector3 = new Vector3(1, -1, 1).normalize(); + + public isCSMDirectionalLight = true; + + constructor(color?: vec3, intensity?: number) { + super(); + + if (color) { + this.color = color; + } + + if (intensity !== undefined) { + this.intensity = intensity; + } + + this.mainFrustum = new CSMFrustum(); + + for (let i = 0; i < NUM_CSM_CASCADES; i++) { + const light = new DirectionalLight(); + light.color.fromArray(this.color); + light.intensity = this.intensity; + light.castShadow = this._castShadow; + light.shadow.mapSize.setScalar(SHADOW_MAP_SIZE); + light.shadow.camera.near = SHADOW_NEAR; + light.shadow.camera.far = SHADOW_FAR; + this.add(light); + this.add(light.target); + this.lights.push(light); + } + + if (DEBUG) { + const helper = new CSMHelper(); + this.helper = helper; + this.add(helper); + } + } + + public getColor(): vec3 { + return this.color; + } + + public setColor(color: vec3) { + vec3.copy(this.color, color); + + for (let i = 0; i < this.lights.length; i++) { + this.lights[i].color.fromArray(color); + } + } + + public getIntensity(): number { + return this.intensity; + } + + public setIntensity(intensity: number) { + this.intensity = intensity; + + for (let i = 0; i < this.lights.length; i++) { + this.lights[i].intensity = intensity; + } + } + + public getCastShadow(): boolean { + return this._castShadow; + } + + public setCastShadow(castShadow: boolean) { + this._castShadow = true; + + for (let i = 0; i < this.lights.length; i++) { + this.lights[i].castShadow = castShadow; + } + } + + private updateFrustums(camera: PerspectiveCamera) { + const far = Math.min(camera.far, MAX_SHADOW_DISTANCE); + + logarithmicSplits.length = 0; + uniformSplits.length = 0; + this.splits.length = 0; + + for (let i = 1; i < NUM_CSM_CASCADES; i++) { + logarithmicSplits.push((camera.near * (far / camera.near) ** (i / NUM_CSM_CASCADES)) / far); + uniformSplits.push((camera.near + ((far - camera.near) * i) / NUM_CSM_CASCADES) / far); + } + + logarithmicSplits.push(1); + uniformSplits.push(1); + + for (let i = 1; i < NUM_CSM_CASCADES; i++) { + this.splits.push(MathUtils.lerp(uniformSplits[i - 1], logarithmicSplits[i - 1], 0.5)); + } + + this.splits.push(1); + + camera.updateProjectionMatrix(); + + this.mainFrustum.setFromProjectionMatrix(camera.projectionMatrix, MAX_SHADOW_DISTANCE); + this.mainFrustum.split(this.splits, this.frustums); + + for (let i = 0; i < this.frustums.length; i++) { + const light = this.lights[i]; + const shadowCamera = light.shadow.camera; + const frustum = this.frustums[i]; + + // Get the two points that represent that furthest points on the frustum assuming + // that's either the diagonal across the far plane or the diagonal across the whole + // frustum itself. + const nearVerts = frustum.vertices.near; + const farVerts = frustum.vertices.far; + const point1 = farVerts[0]; + let point2; + if (point1.distanceTo(farVerts[2]) > point1.distanceTo(nearVerts[2])) { + point2 = farVerts[2]; + } else { + point2 = nearVerts[2]; + } + + let squaredBBWidth = point1.distanceTo(point2); + + // expand the shadow extents by the fade margin + // TODO: shouldn't this be Math.min? + const far = Math.max(camera.far, MAX_SHADOW_DISTANCE); + const linearDepth = frustum.vertices.far[0].z / (far - camera.near); + const margin = 0.25 * Math.pow(linearDepth, 2.0) * (far - camera.near); + + squaredBBWidth += margin; + + shadowCamera.left = -squaredBBWidth / 2; + shadowCamera.right = squaredBBWidth / 2; + shadowCamera.top = squaredBBWidth / 2; + shadowCamera.bottom = -squaredBBWidth / 2; + shadowCamera.updateProjectionMatrix(); + + light.shadow.bias = SHADOW_BIAS * squaredBBWidth; + } + } + + public updateMaterial(camera: PerspectiveCamera, material: Material) { + for (let i = 0; i < NUM_CSM_CASCADES; i++) { + const splitRange = material.userData.csmSplits.value[i] as Vector2; + const amount = this.splits[i]; + const prev = this.splits[i - 1] || 0; + splitRange.x = prev; + splitRange.y = amount; + } + + const far = Math.min(camera.far, MAX_SHADOW_DISTANCE); + + material.userData.cameraNear.value = camera.near; + material.userData.shadowFar.value = far; + } + + public update(camera: PerspectiveCamera) { + if (camera !== this.camera) { + this.camera = camera; + this.updateFrustums(camera); + } + + const frustums = this.frustums; + + for (let i = 0; i < frustums.length; i++) { + const light = this.lights[i]; + const shadowCam = light.shadow.camera; + const texelWidth = (shadowCam.right - shadowCam.left) / SHADOW_MAP_SIZE; + const texelHeight = (shadowCam.top - shadowCam.bottom) / SHADOW_MAP_SIZE; + light.shadow.camera.updateMatrixWorld(true); + cameraToLightMatrix.multiplyMatrices(light.shadow.camera.matrixWorldInverse, camera.matrixWorld); + frustums[i].toSpace(cameraToLightMatrix, lightSpaceFrustum); + + const nearVerts = lightSpaceFrustum.vertices.near; + const farVerts = lightSpaceFrustum.vertices.far; + bbox.makeEmpty(); + + for (let j = 0; j < 4; j++) { + bbox.expandByPoint(nearVerts[j]); + bbox.expandByPoint(farVerts[j]); + } + + bbox.getCenter(center); + center.z = bbox.max.z + LIGHT_MARGIN; + center.x = Math.floor(center.x / texelWidth) * texelWidth; + center.y = Math.floor(center.y / texelHeight) * texelHeight; + center.applyMatrix4(light.shadow.camera.matrixWorld); + + light.position.copy(center); + light.target.position.copy(center); + + light.target.position.x += this.direction.x; + light.target.position.y += this.direction.y; + light.target.position.z += this.direction.z; + } + + if (this.helper) { + this.helper.update(this, camera); + } + } +} diff --git a/src/engine/light/CSMFrustum.ts b/src/engine/light/CSMFrustum.ts new file mode 100644 index 000000000..00858fdfa --- /dev/null +++ b/src/engine/light/CSMFrustum.ts @@ -0,0 +1,103 @@ +import { Vector3, Matrix4 } from "three"; + +const inverseProjectionMatrix = new Matrix4(); + +interface CSMFrustumData { + projectionMatrix?: Matrix4; + maxFar?: number; +} + +export default class CSMFrustum { + vertices: { + near: Vector3[]; + far: Vector3[]; + }; + + constructor(data?: CSMFrustumData) { + data = data || {}; + + this.vertices = { + near: [new Vector3(), new Vector3(), new Vector3(), new Vector3()], + far: [new Vector3(), new Vector3(), new Vector3(), new Vector3()], + }; + + if (data.projectionMatrix !== undefined) { + this.setFromProjectionMatrix(data.projectionMatrix, data.maxFar || 10000); + } + } + + setFromProjectionMatrix(projectionMatrix: Matrix4, maxFar: number) { + const isOrthographic = projectionMatrix.elements[2 * 4 + 3] === 0; + + inverseProjectionMatrix.copy(projectionMatrix).invert(); + + // 3 --- 0 vertices.near/far order + // | | + // 2 --- 1 + // clip space spans from [-1, 1] + + this.vertices.near[0].set(1, 1, -1); + this.vertices.near[1].set(1, -1, -1); + this.vertices.near[2].set(-1, -1, -1); + this.vertices.near[3].set(-1, 1, -1); + this.vertices.near.forEach(function (v) { + v.applyMatrix4(inverseProjectionMatrix); + }); + + this.vertices.far[0].set(1, 1, 1); + this.vertices.far[1].set(1, -1, 1); + this.vertices.far[2].set(-1, -1, 1); + this.vertices.far[3].set(-1, 1, 1); + this.vertices.far.forEach(function (v) { + v.applyMatrix4(inverseProjectionMatrix); + + const absZ = Math.abs(v.z); + if (isOrthographic) { + v.z *= Math.min(maxFar / absZ, 1.0); + } else { + v.multiplyScalar(Math.min(maxFar / absZ, 1.0)); + } + }); + + return this.vertices; + } + + split(breaks: number[], target: CSMFrustum[]) { + while (breaks.length > target.length) { + target.push(new CSMFrustum()); + } + target.length = breaks.length; + + for (let i = 0; i < breaks.length; i++) { + const cascade = target[i]; + + if (i === 0) { + for (let j = 0; j < 4; j++) { + cascade.vertices.near[j].copy(this.vertices.near[j]); + } + } else { + for (let j = 0; j < 4; j++) { + cascade.vertices.near[j].lerpVectors(this.vertices.near[j], this.vertices.far[j], breaks[i - 1]); + } + } + + if (i === breaks.length - 1) { + for (let j = 0; j < 4; j++) { + cascade.vertices.far[j].copy(this.vertices.far[j]); + } + } else { + for (let j = 0; j < 4; j++) { + cascade.vertices.far[j].lerpVectors(this.vertices.near[j], this.vertices.far[j], breaks[i]); + } + } + } + } + + toSpace(cameraMatrix: Matrix4, target: CSMFrustum) { + for (let i = 0; i < 4; i++) { + target.vertices.near[i].copy(this.vertices.near[i]).applyMatrix4(cameraMatrix); + + target.vertices.far[i].copy(this.vertices.far[i]).applyMatrix4(cameraMatrix); + } + } +} diff --git a/src/engine/light/CSMHelper.ts b/src/engine/light/CSMHelper.ts new file mode 100644 index 000000000..27588c491 --- /dev/null +++ b/src/engine/light/CSMHelper.ts @@ -0,0 +1,158 @@ +import { + Group, + Mesh, + LineSegments, + BufferGeometry, + LineBasicMaterial, + Box3Helper, + Box3, + PlaneGeometry, + MeshBasicMaterial, + BufferAttribute, + DoubleSide, + PerspectiveCamera, + Color, +} from "three"; + +import { CSMDirectionalLight, NUM_CSM_CASCADES } from "./CSMDirectionalLight"; + +class CSMHelper extends Group { + public displayFrustum: boolean; + public displayPlanes: boolean; + public displayShadowBounds: boolean; + + private frustumLines: LineSegments; + private cascadeLines: Box3Helper[]; + private cascadePlanes: Mesh[]; + private shadowLines: Group[]; + + constructor() { + super(); + this.displayFrustum = true; + this.displayPlanes = false; + this.displayShadowBounds = true; + + const indices = new Uint16Array([0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7]); + const positions = new Float32Array(24); + const frustumGeometry = new BufferGeometry(); + frustumGeometry.setIndex(new BufferAttribute(indices, 1)); + frustumGeometry.setAttribute("position", new BufferAttribute(positions, 3, false)); + const frustumLines = new LineSegments(frustumGeometry, new LineBasicMaterial()); + this.add(frustumLines); + + this.frustumLines = frustumLines; + this.cascadeLines = []; + this.cascadePlanes = []; + this.shadowLines = []; + } + + updateVisibility() { + const displayFrustum = this.displayFrustum; + const displayPlanes = this.displayPlanes; + const displayShadowBounds = this.displayShadowBounds; + + const frustumLines = this.frustumLines; + const cascadeLines = this.cascadeLines; + const cascadePlanes = this.cascadePlanes; + const shadowLines = this.shadowLines; + + for (let i = 0, l = cascadeLines.length; i < l; i++) { + const cascadeLine = cascadeLines[i]; + const cascadePlane = cascadePlanes[i]; + const shadowLineGroup = shadowLines[i]; + + cascadeLine.visible = displayFrustum; + cascadePlane.visible = displayFrustum && displayPlanes; + shadowLineGroup.visible = displayShadowBounds; + } + + frustumLines.visible = displayFrustum; + } + + update(csm: CSMDirectionalLight, camera: PerspectiveCamera) { + const cascades = NUM_CSM_CASCADES; + const mainFrustum = csm.mainFrustum; + const frustums = csm.frustums; + const lights = csm.lights; + + const frustumLines = this.frustumLines; + const frustumLinePositions = frustumLines.geometry.getAttribute("position"); + const cascadeLines = this.cascadeLines; + const cascadePlanes = this.cascadePlanes; + const shadowLines = this.shadowLines; + + this.position.copy(camera.position); + this.quaternion.copy(camera.quaternion); + this.scale.copy(camera.scale); + this.updateMatrixWorld(true); + + while (cascadeLines.length > cascades) { + this.remove(cascadeLines.pop() as Box3Helper); + this.remove(cascadePlanes.pop() as Mesh); + this.remove(shadowLines.pop() as Group); + } + + while (cascadeLines.length < cascades) { + const cascadeLine = new Box3Helper(new Box3(), 0xffffff as unknown as Color); + const planeMat = new MeshBasicMaterial({ transparent: true, opacity: 0.1, depthWrite: false, side: DoubleSide }); + const cascadePlane = new Mesh(new PlaneGeometry(), planeMat); + const shadowLineGroup = new Group(); + const shadowLine = new Box3Helper(new Box3(), 0xffff00 as unknown as Color); + shadowLineGroup.add(shadowLine); + + this.add(cascadeLine); + this.add(cascadePlane); + this.add(shadowLineGroup); + + cascadeLines.push(cascadeLine); + cascadePlanes.push(cascadePlane); + shadowLines.push(shadowLineGroup); + } + + for (let i = 0; i < cascades; i++) { + const frustum = frustums[i]; + const light = lights[i]; + const shadowCam = light.shadow.camera; + const farVerts = frustum.vertices.far; + + const cascadeLine = cascadeLines[i]; + const cascadePlane = cascadePlanes[i]; + const shadowLineGroup = shadowLines[i]; + const shadowLine = shadowLineGroup.children[0] as Box3Helper; + + cascadeLine.box.min.copy(farVerts[2]); + cascadeLine.box.max.copy(farVerts[0]); + cascadeLine.box.max.z += 1e-4; + + cascadePlane.position.addVectors(farVerts[0], farVerts[2]); + cascadePlane.position.multiplyScalar(0.5); + cascadePlane.scale.subVectors(farVerts[0], farVerts[2]); + cascadePlane.scale.z = 1e-4; + + this.remove(shadowLineGroup); + shadowLineGroup.position.copy(shadowCam.position); + shadowLineGroup.quaternion.copy(shadowCam.quaternion); + shadowLineGroup.scale.copy(shadowCam.scale); + shadowLineGroup.updateMatrixWorld(true); + this.attach(shadowLineGroup); + + shadowLine.box.min.set(shadowCam.bottom, shadowCam.left, -shadowCam.far); + shadowLine.box.max.set(shadowCam.top, shadowCam.right, -shadowCam.near); + } + + const nearVerts = mainFrustum.vertices.near; + const farVerts = mainFrustum.vertices.far; + frustumLinePositions.setXYZ(0, farVerts[0].x, farVerts[0].y, farVerts[0].z); + frustumLinePositions.setXYZ(1, farVerts[3].x, farVerts[3].y, farVerts[3].z); + frustumLinePositions.setXYZ(2, farVerts[2].x, farVerts[2].y, farVerts[2].z); + frustumLinePositions.setXYZ(3, farVerts[1].x, farVerts[1].y, farVerts[1].z); + + frustumLinePositions.setXYZ(4, nearVerts[0].x, nearVerts[0].y, nearVerts[0].z); + frustumLinePositions.setXYZ(5, nearVerts[3].x, nearVerts[3].y, nearVerts[3].z); + frustumLinePositions.setXYZ(6, nearVerts[2].x, nearVerts[2].y, nearVerts[2].z); + frustumLinePositions.setXYZ(7, nearVerts[1].x, nearVerts[1].y, nearVerts[1].z); + frustumLinePositions.needsUpdate = true; + } +} + +export { CSMHelper }; diff --git a/src/engine/light/light.render.ts b/src/engine/light/light.render.ts index 50ce17309..0b46d41d5 100644 --- a/src/engine/light/light.render.ts +++ b/src/engine/light/light.render.ts @@ -1,11 +1,13 @@ -import { DirectionalLight, Light, PointLight, Scene, SpotLight } from "three"; +import { DirectionalLight, Light, Material, PointLight, Scene, SpotLight } from "three"; import { getReadObjectBufferView, ReadObjectTripleBufferView } from "../allocator/ObjectBufferView"; +import { getModule } from "../module/module.common"; import { RendererNodeTripleBuffer } from "../node/node.common"; -import { LocalNode, updateTransformFromNode } from "../node/node.render"; -import { RenderThreadState } from "../renderer/renderer.render"; +import { LocalNode, setWorldDirectionFromNode, updateTransformFromNode } from "../node/node.render"; +import { RendererModule, RenderThreadState } from "../renderer/renderer.render"; import { ResourceId } from "../resource/resource.common"; import { getLocalResource } from "../resource/resource.render"; +import { CSMDirectionalLight } from "./CSMDirectionalLight"; import { DirectionalLightTripleBuffer, LightType, @@ -78,6 +80,7 @@ export function updateNodeLight( node: LocalNode, nodeReadView: ReadObjectTripleBufferView ) { + const rendererModule = getModule(ctx, RendererModule); const currentLightResourceId = node.light?.resourceId || 0; const nextLightResourceId = nodeReadView.light[0]; @@ -85,6 +88,10 @@ export function updateNodeLight( if (currentLightResourceId !== nextLightResourceId) { if (node.lightObject) { + if ("isCSMDirectionalLight" in node.lightObject) { + rendererModule.mainLight = undefined; + } + scene.remove(node.lightObject); node.lightObject = undefined; } @@ -102,36 +109,48 @@ export function updateNodeLight( const lightType = node.light.type; - let light: Light | undefined; + let light: Light | CSMDirectionalLight | undefined; if (lightType === LightType.Directional) { - let directionalLight = node.lightObject as DirectionalLight | undefined; + let directionalLight = node.lightObject as CSMDirectionalLight | DirectionalLight | undefined; if (!directionalLight) { - directionalLight = new DirectionalLight(); - // Ensure light points down negative z axis - directionalLight.target.position.set(0, 0, -1); - directionalLight.add(directionalLight.target); - - // TODO: Move to CSM - directionalLight.shadow.camera.top = 100; - directionalLight.shadow.camera.bottom = -100; - directionalLight.shadow.camera.left = -100; - directionalLight.shadow.camera.right = 100; - directionalLight.shadow.camera.near = 10; - directionalLight.shadow.camera.far = 600; - directionalLight.shadow.bias = 0.0001; - directionalLight.shadow.normalBias = 0.2; - directionalLight.shadow.mapSize.set(2048, 2048); + if (!rendererModule.mainLight) { + const csmLight = new CSMDirectionalLight(); + directionalLight = csmLight; + rendererModule.mainLight = csmLight; + } else { + console.warn( + "Warning: Scene uses more than one directional light. CSM will only be enabled for the first light." + ); + directionalLight = new DirectionalLight(); + directionalLight.target.position.set(0, 0, -1); + directionalLight.add(directionalLight.target); + directionalLight.shadow.camera.top = 100; + directionalLight.shadow.camera.bottom = -100; + directionalLight.shadow.camera.left = -100; + directionalLight.shadow.camera.right = 100; + directionalLight.shadow.camera.near = 10; + directionalLight.shadow.camera.far = 600; + directionalLight.shadow.bias = 0.0001; + directionalLight.shadow.normalBias = 0.2; + directionalLight.shadow.mapSize.setScalar(2048); + } scene.add(directionalLight); } const sharedLight = getReadObjectBufferView(node.light.lightTripleBuffer); - directionalLight.color.fromArray(sharedLight.color); - directionalLight.intensity = sharedLight.intensity[0]; - directionalLight.castShadow = !!sharedLight.castShadow[0]; + if ("isCSMDirectionalLight" in directionalLight) { + directionalLight.setColor(sharedLight.color); + directionalLight.setIntensity(sharedLight.intensity[0]); + directionalLight.setCastShadow(!!sharedLight.castShadow[0]); + } else { + directionalLight.color.fromArray(sharedLight.color); + directionalLight.intensity = sharedLight.intensity[0]; + directionalLight.castShadow = !!sharedLight.castShadow[0]; + } light = directionalLight; } else if (lightType === LightType.Point) { @@ -175,8 +194,35 @@ export function updateNodeLight( } if (light) { - updateTransformFromNode(ctx, nodeReadView, light); + if ("isCSMDirectionalLight" in light) { + setWorldDirectionFromNode(nodeReadView, light.direction, light); + } else { + updateTransformFromNode(ctx, nodeReadView, light); + } } node.lightObject = light; } + +export function updateMainLight(ctx: RenderThreadState, cameraNode: LocalNode | undefined, nodes: LocalNode[]) { + const rendererModule = getModule(ctx, RendererModule); + const mainLight = rendererModule.mainLight; + const camera = cameraNode?.cameraObject; + + if (!mainLight || !camera || !("isPerspectiveCamera" in camera)) { + return; + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + if (node.meshPrimitiveObjects) { + for (let i = 0; i < node.meshPrimitiveObjects.length; i++) { + const meshPrimitive = node.meshPrimitiveObjects[i]; + mainLight.updateMaterial(camera, meshPrimitive.material as Material); + } + } + } + + mainLight.update(camera); +} diff --git a/src/engine/material/patchShaderChunks.ts b/src/engine/material/patchShaderChunks.ts index 00f9b08bb..924770360 100644 --- a/src/engine/material/patchShaderChunks.ts +++ b/src/engine/material/patchShaderChunks.ts @@ -157,4 +157,85 @@ export default function patchShaderChunks() { #endif ` ); + + // Cascaded Shadow Maps (CSM) + ShaderChunk.lights_fragment_begin = ShaderChunk.lights_fragment_begin.replace( + "#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )", + `#if ( NUM_DIR_LIGHTS > 0) && defined( RE_Direct ) && defined( USE_CSM ) && defined( CSM_CASCADES ) + + DirectionalLight directionalLight; + + #if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0 + DirectionalLightShadow directionalLightShadow; + + float linearDepth = (vViewPosition.z) / (shadowFar - cameraNear); + + vec2 cascade; + float cascadeCenter; + float closestEdge; + float margin; + float csmx; + float csmy; + + #pragma unroll_loop_start + for ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) { + directionalLight = directionalLights[ i ]; + getDirectionalLightInfo( directionalLight, geometry, directLight ); + + #if ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS ) + // NOTE: Depth gets larger away from the camera. + // cascade.x is closer, cascade.y is further + cascade = CSM_cascades[ i ]; + cascadeCenter = ( cascade.x + cascade.y ) / 2.0; + closestEdge = linearDepth < cascadeCenter ? cascade.x : cascade.y; + margin = 0.25 * pow( closestEdge, 2.0 ); + csmx = cascade.x - margin / 2.0; + csmy = cascade.y + margin / 2.0; + + if( linearDepth >= csmx && ( linearDepth < csmy || UNROLLED_LOOP_INDEX == CSM_CASCADES - 1 ) ) { + float dist = min( linearDepth - csmx, csmy - linearDepth ); + float ratio = clamp( dist / margin, 0.0, 1.0 ); + vec3 prevColor = directLight.color; + directionalLightShadow = directionalLightShadows[ i ]; + directLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0; + bool shouldFadeLastCascade = UNROLLED_LOOP_INDEX == CSM_CASCADES - 1 && linearDepth > cascadeCenter; + directLight.color = mix( prevColor, directLight.color, shouldFadeLastCascade ? ratio : 1.0 ); + ReflectedLight prevLight = reflectedLight; + RE_Direct( directLight, geometry, material, reflectedLight ); + bool shouldBlend = UNROLLED_LOOP_INDEX != CSM_CASCADES - 1 || UNROLLED_LOOP_INDEX == CSM_CASCADES - 1 && linearDepth < cascadeCenter; + float blendRatio = shouldBlend ? ratio : 1.0; + reflectedLight.directDiffuse = mix( prevLight.directDiffuse, reflectedLight.directDiffuse, blendRatio ); + reflectedLight.directSpecular = mix( prevLight.directSpecular, reflectedLight.directSpecular, blendRatio ); + reflectedLight.indirectDiffuse = mix( prevLight.indirectDiffuse, reflectedLight.indirectDiffuse, blendRatio ); + reflectedLight.indirectSpecular = mix( prevLight.indirectSpecular, reflectedLight.indirectSpecular, blendRatio ); + } + #endif + } + #pragma unroll_loop_end + + #if ( NUM_DIR_LIGHTS > NUM_DIR_LIGHT_SHADOWS) + // compute the lights not casting shadows (if any) + #pragma unroll_loop_start + for ( int i = NUM_DIR_LIGHT_SHADOWS; i < NUM_DIR_LIGHTS; i ++ ) { + directionalLight = directionalLights[ i ]; + getDirectionalLightInfo( directionalLight, geometry, directLight ); + RE_Direct( directLight, geometry, material, reflectedLight ); + } + #pragma unroll_loop_end + #endif + + #endif + + #elif ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct ) + ` + ); + + ShaderChunk.lights_pars_begin = + ` + #if defined( USE_CSM ) && defined( CSM_CASCADES ) + uniform vec2 CSM_cascades[CSM_CASCADES]; + uniform float cameraNear; + uniform float shadowFar; + #endif + ` + ShaderChunk.lights_pars_begin; } diff --git a/src/engine/mesh/mesh.render.ts b/src/engine/mesh/mesh.render.ts index 5cc41b366..f48caca5b 100644 --- a/src/engine/mesh/mesh.render.ts +++ b/src/engine/mesh/mesh.render.ts @@ -23,12 +23,14 @@ import { LinearEncoding, InstancedBufferAttribute, InstancedBufferGeometry, + Vector2, } from "three"; import { LocalAccessor } from "../accessor/accessor.render"; import { getReadObjectBufferView, ReadObjectTripleBufferView } from "../allocator/ObjectBufferView"; // import { Skeleton, SkinnedMesh } from "../animation/Skeleton"; import { GLTFMesh } from "../gltf/GLTF"; +import { MAX_SHADOW_DISTANCE, NUM_CSM_CASCADES } from "../light/CSMDirectionalLight"; import { MaterialType } from "../material/material.common"; import { createDefaultMaterial, @@ -448,6 +450,8 @@ function createMeshPrimitiveObject( } if (!("isMeshBasicMaterial" in material)) { + material.defines.USE_CSM = ""; + material.defines.CSM_CASCADES = NUM_CSM_CASCADES; material.defines.USE_ENVMAP = ""; material.defines.ENVMAP_MODE_REFLECTION = ""; material.defines.ENVMAP_TYPE_CUBE_UV = ""; @@ -457,12 +461,18 @@ function createMeshPrimitiveObject( } if (!material.userData.beforeCompileHook) { + const csmSplits = new Uniform(Array.from({ length: NUM_CSM_CASCADES }, () => new Vector2())); + const cameraNear = new Uniform(1); + const shadowFar = new Uniform(MAX_SHADOW_DISTANCE); const lightMapTransform = new Uniform(new Matrix3().setUvTransform(0, 0, 1, 1, 0, 0, 0)); const reflectionProbesMap = new Uniform(rendererModule.reflectionProbesMap); const reflectionProbeParams = new Uniform(new Vector3()); const reflectionProbeSampleParams = new Uniform(new Vector3()); material.onBeforeCompile = (shader) => { + shader.uniforms.CSM_cascades = csmSplits; + shader.uniforms.cameraNear = cameraNear; + shader.uniforms.shadowFar = shadowFar; shader.uniforms.lightMapTransform = lightMapTransform; shader.uniforms.reflectionProbesMap = reflectionProbesMap; shader.uniforms.reflectionProbeParams = reflectionProbeParams; @@ -470,6 +480,9 @@ function createMeshPrimitiveObject( }; material.userData.beforeCompileHook = true; + material.userData.csmSplits = csmSplits; + material.userData.cameraNear = cameraNear; + material.userData.shadowFar = shadowFar; material.userData.lightMapTransform = lightMapTransform; material.userData.reflectionProbesMap = reflectionProbesMap; material.userData.reflectionProbeParams = reflectionProbeParams; diff --git a/src/engine/node/node.render.ts b/src/engine/node/node.render.ts index 358a03421..6ba0c7e1e 100644 --- a/src/engine/node/node.render.ts +++ b/src/engine/node/node.render.ts @@ -20,6 +20,7 @@ import { getReadObjectBufferView, ReadObjectTripleBufferView } from "../allocato import { LocalCameraResource, updateNodeCamera } from "../camera/camera.render"; import { clamp } from "../component/transform"; import { tickRate } from "../config.common"; +import { CSMDirectionalLight } from "../light/CSMDirectionalLight"; import { LocalLightResource, updateNodeLight } from "../light/light.render"; import { LocalInstancedMesh, LocalLightMap, LocalMesh, LocalSkinnedMesh, updateNodeMesh } from "../mesh/mesh.render"; import { getModule } from "../module/module.common"; @@ -48,7 +49,7 @@ export interface LocalNode { camera?: LocalCameraResource; cameraObject?: PerspectiveCamera | OrthographicCamera; light?: LocalLightResource; - lightObject?: Light; + lightObject?: Light | CSMDirectionalLight; tilesRenderer?: LocalTilesRendererResource; reflectionProbe?: LocalReflectionProbeResource; reflectionProbeObject?: ReflectionProbe; @@ -143,6 +144,21 @@ export function setTransformFromNode( object3D.scale.copy(tempScale); object3D.visible = !!nodeReadView.visible[0]; + object3D.layers.mask = nodeReadView.layers[0]; +} + +export function setWorldDirectionFromNode( + nodeReadView: ReadObjectTripleBufferView, + direction: Vector3, + object3D: Object3D +) { + tempMatrix4.fromArray(nodeReadView.worldMatrix); + + const e = tempMatrix4.elements; + direction.set(e[8], e[9], e[10]).negate().normalize(); + + object3D.visible = !!nodeReadView.visible[0]; + object3D.layers.mask = nodeReadView.layers[0]; } export function updateLocalNodeResources( diff --git a/src/engine/renderer/renderer.render.ts b/src/engine/renderer/renderer.render.ts index 9977b7914..4d651fcc5 100644 --- a/src/engine/renderer/renderer.render.ts +++ b/src/engine/renderer/renderer.render.ts @@ -22,6 +22,7 @@ import { onLoadLocalDirectionalLightResource, onLoadLocalPointLightResource, onLoadLocalSpotLightResource, + updateMainLight, } from "../light/light.render"; import { UnlitMaterialResourceType, StandardMaterialResourceType } from "../material/material.common"; import { @@ -87,6 +88,7 @@ import { updateReflectionProbeTextureArray, } from "../reflection-probe/reflection-probe.render"; import { ReflectionProbe } from "../reflection-probe/ReflectionProbe"; +import { CSMDirectionalLight } from "../light/CSMDirectionalLight"; export interface RenderThreadState extends BaseThreadContext { canvas?: HTMLCanvasElement; @@ -118,6 +120,7 @@ export interface RendererModuleState { pmremGenerator: PMREMGenerator; prevCameraResource?: ResourceId; prevSceneResource?: ResourceId; + mainLight?: CSMDirectionalLight; } export const RendererModule = defineModule({ @@ -312,6 +315,7 @@ export function RendererSystem(ctx: RenderThreadState) { updateReflectionProbeTextureArray(ctx, activeSceneResource); updateNodeReflections(ctx, activeSceneResource, rendererModule.nodes); + updateMainLight(ctx, activeCameraNode, rendererModule.nodes); if (activeSceneResource && activeCameraNode && activeCameraNode.cameraObject) { renderPipeline.render(activeSceneResource.scene, activeCameraNode.cameraObject, ctx.dt);