Detailed explanation of how key features were implemented in the Portalt platform
The Portalt platform is implemented using a modern, scalable architecture that combines web technologies with Unity-based VR/AR capabilities. The system is built on a microservices architecture with clear separation of concerns between the admin dashboard, Unity applications, and backend services.
The architecture follows a layered approach, with each component handling specific responsibilities:
The platform leverages modern web technologies and cloud services to deliver a robust VR/AR experience:
The frontend stack is chosen for its developer experience, type safety, and component reusability.
The backend stack prioritizes performance, scalability, and real-time capabilities.
The platform implements several key features that work together to provide a seamless VR/AR experience:
The implementation follows a modular, component-based approach with emphasis on:
This approach ensures maintainable code, reliable performance, and robust security.
The platform integrates with various services and systems to provide a complete solution:
The implementation includes several performance optimizations to ensure smooth operation:
These optimizations are crucial for delivering a responsive VR/AR experience.
Security is implemented at multiple levels to protect user data and assets:
The implementation includes comprehensive testing to ensure reliability:
The implementation is designed to support future enhancements:
The application implements a comprehensive VR environment creation system that allows users to create, configure, and manage 3D scenes for VR activities. The system supports 3D object placement, scene configuration, and real-time synchronization with Unity applications.
interface SceneConfiguration {
_id?: string;
activity_id: string;
scene_id: string;
objects: SceneObject[];
orgId: string;
createdAt: Date;
updatedAt: Date;
}
interface SceneObject {
object_id: string;
modelUrl: string;
position: Vector3;
rotation: Vector3;
scale: Vector3;
}
interface Vector3 {
x: number;
y: number;
z: number;
}
export function SceneEditor({ activity }: SceneEditorProps) {
const [selectedScene, setSelectedScene] = useState<string | null>(null);
const [sceneConfigs, setSceneConfigs] = useState<Record<string, any>>({});
// Initialize scene for VR activities
useEffect(() => {
if (activity.format === 'VR' && activity.scenes?.[0]) {
setSelectedScene(activity.scenes[0].id);
setSceneConfigs(prev => ({
...prev,
[activity.scenes[0].id]: activity.scenes[0].config
}));
}
}, [activity]);
}
const handleAddArtifact = async (sceneId: string, artifact: any) => {
const newObject = {
object_id: crypto.randomUUID(),
modelUrl: artifact.modelUrl,
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
};
const currentConfig = sceneConfigs[sceneId] || { objects: [] };
const currentObjects = Array.isArray(currentConfig.objects) ? currentConfig.objects : [];
await fetch(`/api/scenes-configuration/${sceneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
objects: [...currentObjects, newObject]
})
});
};
const handleUpdateArtifact = async (sceneId: string, objectId: string, updated: any) => {
const updatedObjects = sceneConfigs[sceneId]?.objects?.map((obj: { object_id: string; }) =>
obj.object_id === objectId ? { ...obj, ...updated } : obj
) || [];
await fetch(`/api/scenes-configuration/${sceneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
objects: updatedObjects
})
});
};
Unity applications can access scene configurations through public API endpoints:
// Example Unity API call
const response = await fetch(`/api/scenes-configuration/${sceneId}?orgId=${orgId}`);
const config = await response.json();
Unity applications can access 3D models through public COS URLs:
// Example model URL structure
const modelUrl = `https://${bucketName}.s3.${region}.cloud-object-storage.appdomain.cloud/documents/${modelKey}`;
// Example validation
if (!format || !["AR", "VR"].includes(format)) {
return res.status(400).json({ message: "Valid format (AR/VR) is required" });
}
try {
const parseResult = ActivitySchema.safeParse(newActivity);
if (!parseResult.success) {
return res.status(400).json({ errors: parseResult.error.format() });
}
} catch (error) {
console.error("[ACTIVITIES_POST]", error);
return res.status(500).json({ message: "Internal Error" });
}
The platform provides an intuitive system for placing and manipulating 3D objects within the VR/AR scenes directly from the Unity Admin Scene. This feature, often referred to as the "Tractor Beam Mode," allows administrators to select, move, rotate, and scale objects using mouse and keyboard controls.
The interactive placement system is primarily managed by a set of scripts within the `Assets/Custom/Custom Scripts/` folder in the Unity project. The `AdminController.cs` script orchestrates the switching between UI interaction and object manipulation mode.
// Simplified logic from AdminController.cs
public class AdminController : MonoBehaviour
{
public KeyCode toggleModeKey = KeyCode.Tab;
private bool isInUIMode = false;
public AdminObjectSelector objectSelector;
// ... other references ...
void Update()
{
if (Input.GetKeyDown(toggleModeKey))
{
ToggleMode();
}
// ... handle movement/camera if not in UI mode ...
}
void ToggleMode()
{
isInUIMode = !isInUIMode;
UpdateCursorState(); // Locks/unlocks cursor, enables/disables components
}
void UpdateCursorState()
{
Cursor.lockState = isInUIMode ? CursorLockMode.None : CursorLockMode.Locked;
Cursor.visible = isInUIMode;
if (objectSelector != null) objectSelector.enabled = !isInUIMode;
// Enable/Disable Mover, Rotator, Scaler components...
}
}
// Simplified logic from AdminObjectSelector.cs
public class AdminObjectSelector : MonoBehaviour
{
public Camera adminCamera;
public LayerMask objectLayer;
public LineRenderer tractorBeam;
// ... color settings ...
private GameObject selectedObject;
void Update()
{
UpdateTractorBeam(); // Visualize beam
if (Input.GetMouseButtonDown(0))
{
// Raycast logic...
if (Physics.Raycast(..., out RaycastHit hit, ..., objectLayer))
{
// Selection logic (select, deselect, switch)
// Change object color
}
else
{
// Deselect if clicking empty space
}
}
}
// ... UpdateTractorBeam visualization logic ...
public GameObject GetSelectedObject() { return selectedObject; }
}
// Logic from AdminModeController.cs
public class AdminModeController : MonoBehaviour
{
public enum Mode { Move, Scale, Rotate }
public static Mode CurrentMode = Mode.Move;
public enum RotationAxis { X, Y, Z }
public static RotationAxis CurrentRotationAxis = RotationAxis.Y;
void Update()
{
// Cycle Mode with Shift key
if (Input.GetKeyDown(KeyCode.LeftShift) || Input.GetKeyDown(KeyCode.RightShift)) { /* Cycle Mode */ }
// Cycle Rotation Axis with R key (only in Rotate mode)
if (CurrentMode == Mode.Rotate && Input.GetKeyDown(KeyCode.R)) { /* Cycle Axis */ }
}
}
// Simplified logic from AdminObjectMover.cs
public class AdminObjectMover : MonoBehaviour
{
public AdminObjectSelector selector;
public Transform holdPoint; // Usually the camera transform or a point in front
private float holdDistance = 1f;
void Update() {
GameObject obj = selector.GetSelectedObject();
if (obj != null)
{
// Calculate target position based on camera orientation and distance
Vector3 targetPosition = holdPoint.position + holdPoint.forward * holdDistance;
// Smoothly move the object towards the target position
obj.transform.position = Vector3.Lerp(obj.transform.position, targetPosition, Time.deltaTime * pullSpeed);
// Adjust holdDistance with scroll wheel in Move mode
if (AdminModeController.CurrentMode == AdminModeController.Mode.Move)
{
float scroll = Input.GetAxis("Mouse ScrollWheel");
if (scroll != 0)
{
holdDistance += scroll * scrollSpeed;
holdDistance = Mathf.Clamp(holdDistance, 1f, 100f); // Limit distance
}
}
}
}
}
// Simplified logic from AdminObjectRotator.cs
public class AdminObjectRotator : MonoBehaviour
{
public AdminObjectSelector selector;
public Transform adminCamera; // Used to determine world rotation axis
void Update()
{
if (AdminModeController.CurrentMode == AdminModeController.Mode.Rotate) {
GameObject obj = selector.GetSelectedObject();
float scroll = Input.GetAxisRaw("Mouse ScrollWheel");
if (obj != null && scroll != 0)
{
Vector3 rotationAxis = Vector3.zero;
// Determine world-space axis based on camera orientation
switch (AdminModeController.CurrentRotationAxis)
{
case AdminModeController.RotationAxis.X: rotationAxis = adminCamera.right; break;
case AdminModeController.RotationAxis.Y: rotationAxis = adminCamera.up; break;
case AdminModeController.RotationAxis.Z: rotationAxis = adminCamera.forward; break;
}
// Apply rotation around the calculated axis
obj.transform.Rotate(rotationAxis, scroll * rotationSpeed * Time.deltaTime, Space.World);
}
}
}
}
// Simplified logic from AdminObjectScaler.cs
public class AdminObjectScaler : MonoBehaviour
{
void Update() {
if (AdminModeController.CurrentMode == AdminModeController.Mode.Scale) {
GameObject obj = selector.GetSelectedObject();
float scroll = Input.GetAxisRaw("Mouse ScrollWheel");
if (obj != null && scroll != 0)
{
// Calculate new scale based on scroll input
Vector3 scaleChange = Vector3.one * scroll * scaleSpeed;
Vector3 newScale = obj.transform.localScale + scaleChange;
// Clamp scale to prevent it becoming too small or inverted
newScale = Vector3.Max(newScale, new Vector3(0.05f, 0.05f, 0.05f));
// Apply the new scale
obj.transform.localScale = newScale;
}
}
}
}
// Simplified logic from PortaltSceneExporter.cs
public class PortaltSceneExporter : MonoBehaviour
{
public PortaltServerConfig serverConfig;
public PortaltSceneLoader sceneLoader;
// Main public method to trigger save
public async Task SaveSceneToApi()
{
SceneConfiguration config = sceneLoader.CurrentSceneConfig;
if (config == null) return false;
UpdateSceneObjectTransforms(ref config);
return await UploadSceneConfigurationAsync(config);
}
// Updates the SceneConfiguration object based on GameObjects in the scene
public void UpdateSceneObjectTransforms(ref SceneConfiguration config)
{
SceneObjectMetadata[] sceneObjects = FindObjectsByType(...);
foreach (var configObj in config.objects) {
foreach (SceneObjectMetadata meta in sceneObjects) {
if (meta.objectId == configObj.object_id) {
configObj.position = Vector3Serializable.FromVector3(meta.transform.position);
configObj.rotation = Vector3Serializable.FromVector3(meta.transform.eulerAngles);
configObj.scale = Vector3Serializable.FromVector3(meta.transform.localScale);
break;
}
}
}
config.updatedAt = DateTime.UtcNow.ToString("o");
}
// Sends the configuration JSON to the API endpoint
private async Task UploadSceneConfigurationAsync(SceneConfiguration config)
{
string url = serverConfig.GetSceneConfigUrl(config.scene_id);
string jsonData = Newtonsoft.Json.JsonConvert.SerializeObject(config);
using (UnityWebRequest request = UnityWebRequest.Put(url, jsonData)) {
request.SetRequestHeader("Content-Type", "application/json");
var operation = request.SendWebRequest();
while (!operation.isDone) await Task.Yield();
return request.result == UnityWebRequest.Result.Success;
}
}
// Editor shortcut
private void Update() {
if (Input.GetKeyDown(KeyCode.S) && (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl))) {
_ = SaveSceneToApi();
}
}
}
The application implements a comprehensive authentication and user management system using Clerk, a modern authentication service. The system provides secure user authentication, organization management, and role-based access control.
<!-- src/pages/_app.tsx -->
import { ClerkProvider } from "@clerk/nextjs";
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<ClerkProvider {...pageProps}>
<Layout>
<Component {...pageProps} />
</Layout>
</ClerkProvider>
);
}
<!-- src/pages/sign-in/[[...rest]]/index.tsx -->
import { SignIn } from "@clerk/nextjs";
export default function LoginPage() {
return (
<main className="h-screen w-full flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8">
<h1 className="text-2xl font-bold text-center mb-8 text-gray-800">
Welcome Back
</h1>
<SignIn />
</div>
</main>
);
}
The application implements a custom middleware to handle authentication and organization selection:
// src/middleware.ts
export default clerkMiddleware(async (auth, req) => {
const { userId, sessionClaims, redirectToSignIn } = await auth();
// Handle public routes
if(isPublicRoute(req)){
return NextResponse.next();
}
// Handle unauthenticated users
if (!userId) {
return redirectToSignIn();
}
// Handle organization selection
if (userId && !sessionClaims.org_id &&
req.nextUrl.pathname !== "/organization-select") {
return Response.redirect(new URL("/organization-select", req.url));
}
return NextResponse.next();
});
The application implements organization-based access control:
// src/pages/organization-select/index.tsx
import { OrganizationList } from "@clerk/nextjs";
export default function OrganizationSelectPage() {
return (
<main className="h-screen w-full flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md p-8">
<h1 className="text-2xl font-bold text-center mb-8 text-gray-800">
Select Organization
</h1>
<OrganizationList
afterCreateOrganizationUrl="/"
afterSelectOrganizationUrl="/"
/>
</div>
</main>
);
}
All API routes implement organization-based authentication:
// Example API route with authentication
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { orgId } = getAuth(req);
if (!orgId) return res.status(401).json({ message: "Unauthorized" });
// Protected route logic
}
Flow Description:
The data storage and synchronization system implements a hybrid approach using IBM Cloud Object Storage (COS) for asset storage and SQLite for scene configuration data. This architecture ensures efficient storage of large assets while maintaining quick access to configuration data.
import { S3 } from 'aws-sdk';
const cosClient = new S3({
endpoint: process.env.COS_ENDPOINT,
credentials: {
accessKeyId: process.env.COS_ACCESS_KEY_ID,
secretAccessKey: process.env.COS_SECRET_ACCESS_KEY
},
region: process.env.COS_REGION
});
async function uploadAsset(file: File, key: string): Promise {
const params = {
Bucket: process.env.COS_BUCKET_NAME,
Key: key,
Body: file,
ContentType: file.type
};
await cosClient.upload(params).promise();
return cosClient.getSignedUrl('getObject', {
Bucket: process.env.COS_BUCKET_NAME,
Key: key,
Expires: 3600 // URL expires in 1 hour
});
}
interface SceneConfig {
id: string;
name: string;
assets: Array<{
id: string;
url: string;
type: string;
position: { x: number; y: number; z: number; }
}>;
}
async function saveSceneConfig(config: SceneConfig): Promise {
await db.run(`
INSERT OR REPLACE INTO scene_configs (id, name, config)
VALUES (?, ?, ?)
`, [config.id, config.name, JSON.stringify(config)]);
}
app.post('/api/scenes/:id/assets', async (req, res) => {
try {
const file = req.files.asset;
const sceneId = req.params.id;
const key = `scenes/${sceneId}/${file.name}`;
const url = await uploadAsset(file, key);
const config = await getSceneConfig(sceneId);
config.assets.push({
id: uuidv4(),
url,
type: file.type,
position: req.body.position
});
await saveSceneConfig(config);
res.json({ success: true, url });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
The Unity application accesses scene configurations through a dedicated API client that handles:
// Example Unity API call
const response = await fetch(`/api/scenes-configuration/${sceneId}?orgId=${orgId}`);
const config = await response.json();
Unity applications can access assets through public COS URLs:
// Example asset URL structure
const assetUrl = `https://${bucketName}.s3.${region}.cloud-object-storage.appdomain.cloud/documents/${assetKey}`;
// Generate short-lived signed URL for sensitive operations
export async function getSignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
const cos = getCosClient();
return cos.getSignedUrl('getObject', {
Bucket: BUCKET_NAME,
Key: key,
Expires: expiresIn,
});
}
// Example batch asset upload
const uploadPromises = files.map(file =>
uploadDocument(file.buffer, file.name, file.mimetype)
);
await Promise.all(uploadPromises);
try {
await uploadDocument(file, filename, mimeType);
} catch (error) {
console.error('Error uploading to COS:', {
error: error instanceof Error ? {
message: error.message,
stack: error.stack,
name: error.name
} : error,
bucket: BUCKET_NAME,
filename
});
throw error;
}
# Required environment variables
COS_ENDPOINT=https://s3.us-east.cloud-object-storage.appdomain.cloud
COS_ACCESS_KEY_ID=your_access_key_id
COS_SECRET_ACCESS_KEY=your_secret_access_key
COS_REGION=us-east
COS_BUCKET_NAME=your_bucket_name
# Database configuration
SQLITE_PATH=./data/scene_configs.db
MAX_CACHE_SIZE=100MB
ASSET_CACHE_DURATION=3600
The Viewer Scene provides the end-user experience, allowing users (typically in VR) to join and explore the 3D environments configured using the Admin Scene or the web dashboard. It focuses on loading pre-defined scene layouts, connecting users via Ubiq networking, and displaying the assets accurately based on the saved configuration.
The Viewer Scene relies on several key scripts from the `Assets/Custom/Integration Scripts/` folder to manage the connection, loading, and display process.
// Simplified logic from VRViewerManager.cs
public class VRViewerManager : MonoBehaviour
{
public PortaltServerConfigV serverConfig;
public string activityIdToLoad;
public bool requireJoincode = true;
public JoincodeInputManager joincodeManager;
private PortaltSceneLoader sceneLoader;
void Start()
{
SetupComponents(); // Ensure Loader, API Client etc. exist
if (requireJoincode && string.IsNullOrEmpty(serverConfig.joinCode))
{
joincodeManager.ShowJoincodeUI(); // Wait for user input
}
else if (loadOnStart)
{
StartCoroutine(LoadWithDelay()); // Start loading process
}
}
public void LoadFromJoinCode()
{
// Fetch configuration from backend using join code / activity ID
StartCoroutine(FetchAndLoadFromJoinUrl());
}
private IEnumerator FetchAndLoadFromJoinUrl()
{
// Use UnityWebRequest to get SceneConfiguration JSON from join URL
// ... On success:
// SceneConfiguration config = ParseJson(downloadHandler.text);
// sceneLoader.LoadScene(config); // Pass config to loader
}
// Coroutine to handle the web request
private IEnumerator FetchConfigCoroutine(string url, Action onSuccess, Action onError)
{
using (UnityWebRequest webRequest = UnityWebRequest.Get(url))
{
yield return webRequest.SendWebRequest();
if (webRequest.result == UnityWebRequest.Result.Success)
{
try
{
SceneConfiguration config = JsonUtility.FromJson(webRequest.downloadHandler.text);
onSuccess?.Invoke(config);
}
catch (Exception e)
{
onError?.Invoke($"Failed to parse scene configuration: {e.Message}");
}
}
else
{
onError?.Invoke($"Failed to fetch scene configuration: {webRequest.error}");
}
}
}
}
// Simplified logic from PortaltSceneLoader.cs
public class PortaltSceneLoader : MonoBehaviour
{
private List loadedObjects = new List();
public async Task LoadScene(SceneConfiguration config)
{
ClearScene(); // Remove old objects
ShowLoadingScreen(config.scene_id);
currentSceneConfig = config;
List loadingTasks = new List();
foreach (SceneObject objData in config.objects)
{
loadingTasks.Add(LoadAndPlaceObjectAsync(objData));
}
await Task.WhenAll(loadingTasks);
HideLoadingScreen();
return true;
}
// Renamed for clarity
private async Task LoadAndPlaceObjectAsync(SceneObject objData)
{
GameObject instantiatedModel = null;
try
{
// Asynchronously download and import the glTF/GLB model
// GLTFUtility.LoadModelAsync handles the download and parsing
instantiatedModel = await Siccity.GLTFUtility.GLTFImporter.LoadModelAsync(objData.modelUrl);
if (instantiatedModel != null)
{
// Apply transform data from the configuration
ApplyTransform(instantiatedModel, objData);
// Add metadata component (useful for identification or interaction)
SceneObjectMetadata metadata = instantiatedModel.AddComponent();
metadata.Initialize(objData.object_id, objData.modelUrl);
// Optionally add colliders (needed for physics or selection)
AddColliders(instantiatedModel);
// Keep track of the object
loadedObjects.Add(instantiatedModel);
}
}
catch (Exception e)
{
Debug.LogError($"Failed to load model {objData.object_id} from {objData.modelUrl}: {e.Message}");
if (instantiatedModel != null) Destroy(instantiatedModel); // Clean up partially loaded object
}
}
// Renamed for clarity
private void ApplyTransform(GameObject modelInstance, SceneObject objData)
{
// Convert stored position, rotation, scale to Unity's Vector3 and Quaternion
modelInstance.transform.position = objData.position.ToVector3();
modelInstance.transform.rotation = Quaternion.Euler(objData.rotation.ToVector3()); // Convert Euler angles
modelInstance.transform.localScale = objData.scale.ToVector3();
}
// Helper to dynamically add colliders based on mesh bounds
private void AddColliders(GameObject modelInstance)
{
// Example: Add a BoxCollider encompassing all child renderers
Bounds combinedBounds = new Bounds();
Renderer[] renderers = modelInstance.GetComponentsInChildren();
if (renderers.Length > 0)
{
combinedBounds = renderers[0].bounds;
foreach (Renderer renderer in renderers)
{
combinedBounds.Encapsulate(renderer.bounds);
}
BoxCollider collider = modelInstance.AddComponent();
// Adjust collider center and size relative to the model's root transform
collider.center = modelInstance.transform.InverseTransformPoint(combinedBounds.center);
collider.size = modelInstance.transform.InverseTransformVector(combinedBounds.size);
}
}
public void ClearScene()
{
foreach (var obj in loadedObjects) { Destroy(obj); }
loadedObjects.Clear();
// Destroy collider visualizations too
}
}
// Simplified logic from JoincodeInputManager.cs related to connection
public class JoincodeInputManager : MonoBehaviour
{
public InputField joincodeInputField;
public GameObject uiPanel;
private RoomClient roomClient;
void Awake()
{
roomClient = FindObjectOfType();
// ... other setup ...
}
public void ShowJoincodeUI()
{
uiPanel.SetActive(true);
}
public void AttemptJoinWithJoincode()
{
string joincode = joincodeInputField.text;
if (!string.IsNullOrEmpty(joincode))
{
// Potentially fetch server details based on joincode from an API first
// ... (Assuming server details are already in serverConfig for simplicity)
// Configure RoomClient connection definition
var connection = ScriptableObject.CreateInstance();
connection.sendToIp = serverConfig.serverIp;
connection.sendToPort = serverConfig.serverPort;
connection.type = ConnectionDefinition.Type.TcpClient; // Or appropriate type
roomClient.SetDefaultServer(connection);
// Use joincode to join the specific room
roomClient.Join(joincode);
uiPanel.SetActive(false);
// Trigger scene loading via VRViewerManager or event
FindObjectOfType()?.LoadFromJoinCode();
}
}
}