diff --git a/examples/newportal/index.html b/examples/newportal/index.html new file mode 100644 index 0000000..b8d774f --- /dev/null +++ b/examples/newportal/index.html @@ -0,0 +1,182 @@ + + + + + + + Spark • Multi-Portal Example (using SparkPortals) + + + + + + + + + + diff --git a/src/NewSparkRenderer.ts b/src/NewSparkRenderer.ts index 9e4761b..a35febb 100644 --- a/src/NewSparkRenderer.ts +++ b/src/NewSparkRenderer.ts @@ -399,7 +399,7 @@ export class NewSparkRenderer extends THREE.Mesh { this.enableLod = options.enableLod ?? true; // enableDriveLod defaults to true if enableLod is true, false otherwise - this.enableDriveLod = options.enableDriveLod ?? (this.enableLod ? true : false); + this.enableDriveLod = options.enableDriveLod ?? this.enableLod; this.lodSplatCount = options.lodSplatCount; this.lodSplatScale = options.lodSplatScale ?? 1.0; this.globalLodScale = options.globalLodScale ?? 1.0; diff --git a/src/SparkPortals.ts b/src/SparkPortals.ts new file mode 100644 index 0000000..d2b2381 --- /dev/null +++ b/src/SparkPortals.ts @@ -0,0 +1,730 @@ +import * as THREE from "three"; +import { + NewSparkRenderer, + type NewSparkRendererOptions, +} from "./NewSparkRenderer"; + +/** + * Fragment shader for portal disk clipping. + * - diskRadius > 0: render "behind portal" only through the disk + * - diskRadius < 0: render "in front of portal" everywhere except behind disk + * + * Note: This shader uses view-space uniforms and works correctly in non-VR mode. + * In VR mode, visual disk meshes are used instead due to stereo rendering complexity. + */ +export const DISK_PORTAL_FRAGMENT_SHADER = ` +precision highp float; +precision highp int; + +#include + +uniform float near; +uniform float far; +uniform mat4 projectionMatrix; +uniform bool encodeLinear; +uniform float time; +uniform bool debugFlag; +uniform float maxStdDev; +uniform float minAlpha; +uniform bool disableFalloff; +uniform float falloff; + +// Portal disk in VIEW space (computed on CPU before render) +uniform vec3 diskCenter; +uniform vec3 diskNormal; +uniform float diskRadius; +uniform bool diskTwoSided; + +out vec4 fragColor; + +in vec4 vRgba; +in vec2 vSplatUv; +in vec3 vNdc; +flat in uint vSplatIndex; +flat in float adjustedStdDev; + +void main() { + if (diskRadius != 0.0) { + // Portal rendering: + // - diskRadius > 0: render "behind portal" only through the disk (discard outside or in-front-of plane). + // - diskRadius < 0: render "in front of portal" everywhere, but discard fragments behind the plane when looking through the disk. + + // View ray direction from NDC (view space is -Z forward). + vec3 viewDir = normalize(vec3( + vNdc.x / projectionMatrix[0][0], + vNdc.y / projectionMatrix[1][1], + -1.0 + )); + + // Reconstruct view-space *axial* depth (-viewPos.z) from NDC Z. + float ndcZ = vNdc.z; + float depth = (2.0 * near * far) / (far + near - ndcZ * (far - near)); + // Convert axial depth to ray-parameter t (viewPos = t * viewDir). + float rayT = depth / max(1e-6, -viewDir.z); + + float radius = abs(diskRadius); + float radius2 = radius * radius; + bool renderBehind = (diskRadius > 0.0); + + vec3 diskN = normalize(diskNormal); + + // Ray-plane intersection for plane (diskCenter, diskN), with ray origin at (0,0,0). + float denom = dot(viewDir, diskN); + bool allowPortal = diskTwoSided ? (abs(denom) > 1e-6) : (denom < -1e-6); + + bool hitsDisk = false; + float t = 0.0; + if (allowPortal) { + t = dot(diskCenter, diskN) / denom; + if (t > 0.0) { + vec3 q = t * viewDir - diskCenter; + hitsDisk = (dot(q, q) <= radius2); + } + } + + // Small bias to avoid flicker at the plane. + float eps = 1e-4 * max(1.0, abs(t)); + + if (renderBehind) { + // Behind-pass: only render through the portal disk, and only behind the plane along the ray. + if (!hitsDisk) discard; + if (rayT <= t + eps) discard; + } else { + // Front-pass: render everything, except when the ray goes through the disk, discard what's behind the plane. + if (hitsDisk && (rayT >= t - eps)) discard; + } + } + + vec4 rgba = vRgba; + + float z2 = dot(vSplatUv, vSplatUv); + if (z2 > (adjustedStdDev * adjustedStdDev)) { + discard; + } + + float a = rgba.a; + float shifted = sqrt(z2) - max(0.0, a - 1.0); + float exponent = -0.5 * max(1.0, a) * sqr(max(0.0, shifted)); + rgba.a = min(1.0, a) * exp(exponent); + + if (rgba.a < minAlpha) { + discard; + } + if (encodeLinear) { + rgba.rgb = srgbToLinear(rgba.rgb); + } + + #ifdef PREMULTIPLIED_ALPHA + fragColor = vec4(rgba.rgb * rgba.a, rgba.a); + #else + fragColor = rgba; + #endif +} +`; + +/** + * Callback function called when a portal is crossed. + * @param pair The portal pair that was crossed + * @param fromEntry True if crossing from entry to exit, false if crossing from exit to entry + */ +export type PortalCrossCallback = ( + pair: PortalPair, + fromEntry: boolean, +) => void | Promise; + +/** + * A pair of connected portals. Walking through one teleports you to the other. + */ +export interface PortalPair { + /** First portal endpoint */ + entryPortal: THREE.Object3D; + /** Second portal endpoint */ + exitPortal: THREE.Object3D; + /** Radius of this portal pair's disks */ + radius: number; + /** Optional callback function called when this portal is crossed */ + onCross?: PortalCrossCallback; + /** Scratch matrix for tracking portal position before frame updates */ + _entryBefore: THREE.Matrix4; + /** Scratch matrix for tracking portal position before frame updates */ + _exitBefore: THREE.Matrix4; +} + +export interface SparkPortalsOptions { + /** The THREE.WebGLRenderer */ + renderer: THREE.WebGLRenderer; + /** The scene to render */ + scene: THREE.Scene; + /** The main camera */ + camera: THREE.PerspectiveCamera; + /** The local frame (parent of camera, used for teleportation) */ + localFrame: THREE.Group; + /** Options passed to both NewSparkRenderer instances */ + sparkOptions?: Partial; + /** Default portal disk radius for new pairs (default: 1.0) */ + defaultPortalRadius?: number; + /** Epsilon for portal crossing detection (default: 1e-6) */ + portalCrossEps?: number; +} + +/** + * SparkPortals + * + * Portal implementation to connect two non-contiguous areas of a scene. + * Supports multiple portal pairs - each pair connects two locations. + * + * The rough approach is to use two NewSparkRenderers: one for the "front"/portal + * view (portalRenderer), and one for the "behind portal" pass (behindRenderer). + * + * Example: + * ```typescript + * const portals = new SparkPortals({ renderer, scene, camera, localFrame }); + * + * // Add a portal pair + * const pair = portals.addPortalPair(); + * pair.entryPortal.position.set(0, 0, -1); + * pair.exitPortal.position.set(-3, 0, -4.5); + * + * // Add another pair + * const pair2 = portals.addPortalPair({ radius: 0.5 }); + * pair2.entryPortal.position.set(5, 0, 0); + * pair2.exitPortal.position.set(10, 0, 0); + * + * // In animation loop: + * portals.animateLoopHook(); + * ``` + */ +export class SparkPortals { + /** The THREE.WebGLRenderer */ + renderer: THREE.WebGLRenderer; + /** The scene to render */ + scene: THREE.Scene; + /** The main camera */ + camera: THREE.PerspectiveCamera; + /** The local frame (parent of camera, used for teleportation) */ + localFrame: THREE.Group; + + /** Primary renderer with portal shader (added to scene) */ + portalRenderer: NewSparkRenderer; + /** Secondary renderer for behind-portal pass (not in scene) */ + behindRenderer: NewSparkRenderer; + /** Secondary camera for behind-portal view */ + camera2: THREE.PerspectiveCamera; + + /** All portal pairs */ + portalPairs: PortalPair[] = []; + /** Default radius for new portal pairs */ + defaultPortalRadius: number; + /** Epsilon for portal crossing detection */ + portalCrossEps: number; + + /** Used to detect crossing between frames */ + private lastCameraWorld = new THREE.Vector3().setScalar(Number.NaN); + + // Preallocated objects for scratch work to avoid per frame allocations + private scratch = { + quat: new THREE.Quaternion(), + scale: new THREE.Vector3(), + center0: new THREE.Vector3(), + center1: new THREE.Vector3(), + normal0: new THREE.Vector3(), + normal1: new THREE.Vector3(), + centerT: new THREE.Vector3(), + normalT: new THREE.Vector3(), + prevCameraWorld: new THREE.Vector3(), + currCameraWorld: new THREE.Vector3(), + hit: new THREE.Vector3(), + offset: new THREE.Vector3(), + camWorld: new THREE.Matrix4(), + newCamWorld: new THREE.Matrix4(), + invCamLocal: new THREE.Matrix4(), + newLocalFrame: new THREE.Matrix4(), + cameraWorldPos: new THREE.Vector3(), + viewDir: new THREE.Vector3(), + portalCenter: new THREE.Vector3(), + toPortal: new THREE.Vector3(), + }; + + constructor(options: SparkPortalsOptions) { + this.renderer = options.renderer; + this.scene = options.scene; + this.camera = options.camera; + this.localFrame = options.localFrame; + this.defaultPortalRadius = options.defaultPortalRadius ?? 1.0; + this.portalCrossEps = options.portalCrossEps ?? 1e-6; + + const sparkOpts = options.sparkOptions ?? {}; + + // Primary renderer with portal shader (view-space uniforms for non-VR mode) + this.portalRenderer = new NewSparkRenderer({ + renderer: this.renderer, + extraUniforms: { + diskCenter: { value: new THREE.Vector3() }, + diskNormal: { value: new THREE.Vector3() }, + diskRadius: { value: 0 }, + diskTwoSided: { value: false }, + }, + fragmentShader: DISK_PORTAL_FRAGMENT_SHADER, + ...sparkOpts, + }); + this.scene.add(this.portalRenderer); + + // Secondary renderer for behind-portal pass + // enableDriveLod: false prevents this renderer from driving LOD updates, + // avoiding race conditions with portalRenderer's pager operations + this.behindRenderer = new NewSparkRenderer({ + renderer: this.renderer, + enableDriveLod: false, + ...sparkOpts, + }); + + // Secondary camera for behind-portal view + this.camera2 = this.camera.clone(); + this.scene.add(this.camera2); + } + + /** + * Set view-space portal uniforms for shader clipping. + * Used in non-VR mode only. + */ + private setPortalDiskUniforms( + camera: THREE.Camera, + portal: THREE.Object3D, + radius: number, + twoSided: boolean, + ): void { + camera.updateMatrixWorld(true); + portal.updateMatrixWorld(true); + + const inverseCamera = camera.matrixWorld.clone().invert(); + const portalInCamera = portal.matrixWorld.clone().premultiply(inverseCamera); + const portalQuat = new THREE.Quaternion(); + + const uniforms = this.portalRenderer.uniforms as typeof this.portalRenderer.uniforms & { + diskCenter: { value: THREE.Vector3 }; + diskNormal: { value: THREE.Vector3 }; + diskRadius: { value: number }; + diskTwoSided: { value: boolean }; + }; + + portalInCamera.decompose( + uniforms.diskCenter.value, + portalQuat, + new THREE.Vector3(), + ); + + uniforms.diskNormal.value.set(0, 0, 1).applyQuaternion(portalQuat); + uniforms.diskRadius.value = radius; + uniforms.diskTwoSided.value = twoSided; + } + + /** Disable shader-based portal clipping */ + private clearPortalDiskUniforms(): void { + const uniforms = this.portalRenderer.uniforms as typeof this.portalRenderer.uniforms & { + diskRadius: { value: number }; + }; + uniforms.diskRadius.value = 0; + } + + /** + * Add a new portal pair to the system. + * @param options Optional configuration for this pair + * @returns The created PortalPair - position the entryPortal and exitPortal as needed + */ + addPortalPair(options?: { + radius?: number; + onCross?: PortalCrossCallback; + }): PortalPair { + const pair: PortalPair = { + entryPortal: new THREE.Object3D(), + exitPortal: new THREE.Object3D(), + radius: options?.radius ?? this.defaultPortalRadius, + onCross: options?.onCross, + _entryBefore: new THREE.Matrix4(), + _exitBefore: new THREE.Matrix4(), + }; + + this.scene.add(pair.entryPortal); + this.scene.add(pair.exitPortal); + this.portalPairs.push(pair); + + return pair; + } + + /** + * Remove a portal pair from the system. + */ + removePortalPair(pair: PortalPair): void { + const index = this.portalPairs.indexOf(pair); + if (index !== -1) { + this.scene.remove(pair.entryPortal); + this.scene.remove(pair.exitPortal); + this.portalPairs.splice(index, 1); + } + } + + /** + * Get transform from entry portal to exit portal. + */ + getEntryToExitTransform(pair: PortalPair): THREE.Matrix4 { + return pair.entryPortal.matrixWorld + .clone() + .invert() + .premultiply(pair.exitPortal.matrixWorld); + } + + /** + * Get transform from exit portal to entry portal. + */ + getExitToEntryTransform(pair: PortalPair): THREE.Matrix4 { + return pair.exitPortal.matrixWorld + .clone() + .invert() + .premultiply(pair.entryPortal.matrixWorld); + } + + /** Extract portal plane from matrix */ + private getPortalPlane( + matrix: THREE.Matrix4, + outCenter: THREE.Vector3, + outNormal: THREE.Vector3, + ): void { + matrix.decompose(outCenter, this.scratch.quat, this.scratch.scale); + outNormal.set(0, 0, 1).applyQuaternion(this.scratch.quat).normalize(); + } + + /** + * Detect if the user path crosses over a portal. If so, return the parametric position (0,1) + * along the segment where the crossing occurs. If not, return null. + */ + private getSegmentDiskCrossing( + prevCam: THREE.Vector3, + currCam: THREE.Vector3, + beforeMatrix: THREE.Matrix4, + afterMatrix: THREE.Matrix4, + radius: number, + ): number | null { + this.getPortalPlane( + beforeMatrix, + this.scratch.center0, + this.scratch.normal0, + ); + this.getPortalPlane( + afterMatrix, + this.scratch.center1, + this.scratch.normal1, + ); + + const startPlaneDist = this.scratch.offset + .copy(prevCam) + .sub(this.scratch.center0) + .dot(this.scratch.normal0); + const endPlaneDist = this.scratch.offset + .copy(currCam) + .sub(this.scratch.center1) + .dot(this.scratch.normal1); + + if ( + (startPlaneDist > this.portalCrossEps && + endPlaneDist > this.portalCrossEps) || + (startPlaneDist < -this.portalCrossEps && + endPlaneDist < -this.portalCrossEps) + ) { + return null; + } + + const denom = startPlaneDist - endPlaneDist; + if (Math.abs(denom) < this.portalCrossEps) return null; + + const t = startPlaneDist / denom; + if (t < 0 || t > 1) return null; + + this.scratch.hit.lerpVectors(prevCam, currCam, t); + this.scratch.centerT + .copy(this.scratch.center0) + .lerp(this.scratch.center1, t); + this.scratch.normalT + .copy(this.scratch.normal0) + .lerp(this.scratch.normal1, t) + .normalize(); + + this.scratch.offset.copy(this.scratch.hit).sub(this.scratch.centerT); + this.scratch.offset.addScaledVector( + this.scratch.normalT, + -this.scratch.offset.dot(this.scratch.normalT), + ); + + if (this.scratch.offset.lengthSq() > radius * radius) return null; + return t; + } + + /** Teleport camera through portal */ + private teleport(transform: THREE.Matrix4): void { + this.scratch.camWorld.copy(this.camera.matrixWorld); + this.scratch.newCamWorld.copy(this.scratch.camWorld).premultiply(transform); + this.scratch.invCamLocal.copy(this.camera.matrix).invert(); + this.scratch.newLocalFrame + .copy(this.scratch.newCamWorld) + .multiply(this.scratch.invCamLocal); + + this.scratch.newLocalFrame.decompose( + this.localFrame.position, + this.localFrame.quaternion, + this.localFrame.scale, + ); + this.localFrame.updateMatrixWorld(true); + this.camera.updateMatrixWorld(true); + } + + /** + * Check for portal crossing and teleport if needed. + * Checks all portal pairs and takes the earliest crossing. + * Call this after updating controls but before render(). + */ + updateTeleportation(): void { + if (this.portalPairs.length === 0) return; + + this.camera.getWorldPosition(this.scratch.currCameraWorld); + if (!Number.isFinite(this.lastCameraWorld.x)) { + this.lastCameraWorld.copy(this.scratch.currCameraWorld); + return; + } + + this.scratch.prevCameraWorld.copy(this.lastCameraWorld); + + // Store portal matrices before any updates and find earliest crossing + let earliestT: number | null = null; + let crossedPair: PortalPair | null = null; + let crossedEntry = true; // true = crossed entry portal, false = crossed exit portal + + for (const pair of this.portalPairs) { + pair.entryPortal.updateMatrixWorld(true); + pair.exitPortal.updateMatrixWorld(true); + pair._entryBefore.copy(pair.entryPortal.matrixWorld); + pair._exitBefore.copy(pair.exitPortal.matrixWorld); + + // Check entry portal crossing + const entryT = this.getSegmentDiskCrossing( + this.scratch.prevCameraWorld, + this.scratch.currCameraWorld, + pair._entryBefore, + pair.entryPortal.matrixWorld, + pair.radius, + ); + + if (entryT !== null && (earliestT === null || entryT < earliestT)) { + earliestT = entryT; + crossedPair = pair; + crossedEntry = true; + } + + // Check exit portal crossing + const exitT = this.getSegmentDiskCrossing( + this.scratch.prevCameraWorld, + this.scratch.currCameraWorld, + pair._exitBefore, + pair.exitPortal.matrixWorld, + pair.radius, + ); + + if (exitT !== null && (earliestT === null || exitT < earliestT)) { + earliestT = exitT; + crossedPair = pair; + crossedEntry = false; + } + } + + // No portal crossed + if (crossedPair === null) { + this.lastCameraWorld.copy(this.scratch.currCameraWorld); + return; + } + + // Teleport through the crossed portal + if (crossedEntry) { + this.teleport(this.getEntryToExitTransform(crossedPair)); + } else { + this.teleport(this.getExitToEntryTransform(crossedPair)); + } + + this.camera.getWorldPosition(this.lastCameraWorld); + + // Call the portal's onCross callback if provided + if (crossedPair.onCross) { + // Call async callback but don't await (updateTeleportation is synchronous) + // Errors will be logged but won't block teleportation + Promise.resolve(crossedPair.onCross(crossedPair, crossedEntry)).catch( + (error) => { + console.error("Error in portal onCross callback:", error); + }, + ); + } + } + + /** + * Find the most relevant portal for rendering (closest to camera view direction). + * Returns the portal pair and which portal (entry or exit) is primary. + */ + private findPrimaryPortal(): { + pair: PortalPair; + primaryIsEntry: boolean; + primaryPortal: THREE.Object3D; + otherPortal: THREE.Object3D; + } | null { + if (this.portalPairs.length === 0) return null; + + this.camera.getWorldPosition(this.scratch.cameraWorldPos); + this.camera.getWorldDirection(this.scratch.viewDir); + + let bestScore = Number.NEGATIVE_INFINITY; + let bestPair: PortalPair | null = null; + let bestIsEntry = true; + + for (const pair of this.portalPairs) { + // Score entry portal + pair.entryPortal.getWorldPosition(this.scratch.portalCenter); + this.scratch.toPortal + .copy(this.scratch.portalCenter) + .sub(this.scratch.cameraWorldPos); + const entryDist = this.scratch.toPortal.length(); + const entryScore = + this.scratch.toPortal.normalize().dot(this.scratch.viewDir) / entryDist; + + if (entryScore > bestScore) { + bestScore = entryScore; + bestPair = pair; + bestIsEntry = true; + } + + // Score exit portal + pair.exitPortal.getWorldPosition(this.scratch.portalCenter); + this.scratch.toPortal + .copy(this.scratch.portalCenter) + .sub(this.scratch.cameraWorldPos); + const exitDist = this.scratch.toPortal.length(); + const exitScore = + this.scratch.toPortal.normalize().dot(this.scratch.viewDir) / exitDist; + + if (exitScore > bestScore) { + bestScore = exitScore; + bestPair = pair; + bestIsEntry = false; + } + } + + if (!bestPair) return null; + + return { + pair: bestPair, + primaryIsEntry: bestIsEntry, + primaryPortal: bestIsEntry ? bestPair.entryPortal : bestPair.exitPortal, + otherPortal: bestIsEntry ? bestPair.exitPortal : bestPair.entryPortal, + }; + } + + /** + * Render the scene with portals using two-pass rendering. + * Renders the most relevant portal pair (closest to camera view). + * Call this instead of renderer.render() in your animation loop. + * + * In VR mode, shader-based clipping is disabled due to stereo rendering + * complexity. Applications can add their own visual fallbacks if needed. + */ + render(): void { + const isVR = this.renderer.xr.isPresenting; + const primary = this.findPrimaryPortal(); + + // No portals - just render normally + if (!primary) { + this.clearPortalDiskUniforms(); + this.renderer.autoClear = true; + this.renderer.render(this.scene, this.camera); + return; + } + + const { pair, primaryIsEntry, primaryPortal, otherPortal } = primary; + + // VR mode: disable shader clipping, just render normally + // Applications can add their own visual fallbacks to portal.entryPortal/exitPortal + if (isVR) { + this.clearPortalDiskUniforms(); + this.renderer.autoClear = true; + this.portalRenderer.render(this.scene, this.camera); + return; + } + + // Non-VR mode: use shader-based portal clipping + + // Compute camera2 position (transformed through portal) + const camera2Matrix = primaryIsEntry + ? this.camera.matrixWorld + .clone() + .premultiply(this.getEntryToExitTransform(pair)) + : this.camera.matrixWorld + .clone() + .premultiply(this.getExitToEntryTransform(pair)); + camera2Matrix.decompose( + this.camera2.position, + this.camera2.quaternion, + this.camera2.scale, + ); + this.camera2.updateMatrixWorld(true); + + // Share lodInstances from portalRenderer to behindRenderer BEFORE Pass 1. + // This uses previous frame's lodInstances (computed with main camera), + // ensuring both passes use consistent splat selections to avoid flickering. + this.shareLodInstances(); + + // Pass 1: Behind portal view (uses shared lodInstances) + this.setPortalDiskUniforms(this.camera2, otherPortal, pair.radius, true); + this.renderer.autoClear = true; + this.behindRenderer.render(this.scene, this.camera2); + + // Pass 2: Main view (updates portalRenderer's lodInstances for next frame) + this.setPortalDiskUniforms(this.camera, primaryPortal, -pair.radius, true); + this.renderer.autoClear = false; + this.portalRenderer.render(this.scene, this.camera); + } + + /** + * Share lodInstances from portalRenderer to behindRenderer. + * Uses previous frame's values to ensure both passes render consistent splats. + */ + private shareLodInstances(): void { + // Clear and copy lodInstances from portalRenderer to behindRenderer + this.behindRenderer.lodInstances.clear(); + for (const [mesh, data] of this.portalRenderer.lodInstances) { + this.behindRenderer.lodInstances.set(mesh, data); + } + } + + /** + * Convenience hook for animation loop. + * Calls updateTeleportation() then render(). + */ + animateLoopHook(): void { + this.updateTeleportation(); + this.render(); + } + + /** Update camera2 aspect ratio on window resize */ + updateAspect(aspect: number): void { + this.camera2.aspect = aspect; + this.camera2.updateProjectionMatrix(); + } + + /** Dispose of resources */ + dispose(): void { + this.scene.remove(this.portalRenderer); + this.scene.remove(this.camera2); + + for (const pair of this.portalPairs) { + this.scene.remove(pair.entryPortal); + this.scene.remove(pair.exitPortal); + } + this.portalPairs = []; + + this.portalRenderer.dispose(); + this.behindRenderer.dispose(); + } +} diff --git a/src/index.ts b/src/index.ts index 110288e..ec85704 100644 --- a/src/index.ts +++ b/src/index.ts @@ -104,3 +104,10 @@ export * as utils from "./utils"; export { LN_SCALE_MIN, LN_SCALE_MAX } from "./defines"; export * as defines from "./defines"; + +export { + SparkPortals, + type SparkPortalsOptions, + type PortalPair, + DISK_PORTAL_FRAGMENT_SHADER, +} from "./SparkPortals";