Giant Walkthrough Mouth.
Team Lead & Programmer | VR Multiplayer Educational Dentistry | Unity & OVR
6 developers | 4 months | April 2022
Description.
"Giant Walkthrough Mouth" is a cutting-edge multiplayer VR application developed for Oculus, tailored for dental students to explore dental anatomy. Leveraging Unity and Metaverse avatars, students can customize their appearances and engage in collaborative learning. The exhibit features a large-scale model of the mouth with interactable teeth and a comprehensive quiz to test knowledge retention. This unique virtual reality experience offers an innovative approach to dental education.
Contributions.
As Team Lead & Programmer, I facilitated communication between the Product Owner and the development team, coordinated development efforts, and resolved emergent issues. My contributions included:
- Implemented educational information panels, including video, 3D models, and more [Jump].
- Developed magnifying glass tool that reveals root canals
[Jump]. - Integrated multiplayer using PUN 2.
- Configured permission settings and submitted builds to Oculus.
- Assisted the University of Illinois Chicago with Oculus setup and application installation

Info Panels.
Description.
As a part of the educative experience, users can point at a tooth to learn more about its position, functionality, and more. This includes a rotating 3D model, a video, and interactive root canal viewing.
Challenges.
The bulk of the work in the information panels was in setting up the Unity UI Canvas, which is more tedious than challenging. The MultiplayerToothManager.cs script opens up each tooth's respective panel. From there, MultiplayerCanvasManager.cs handles the panel's interactions. The biggest coding challenge was an interaction between the “3D Model” and “Root Canal.” When the model is open and a user selects “Root Canal,” the user likely expects the root canal to appear on first selection; however, this wasn't the initial case because these buttons shared the same GameObject. To solve this, I added a variable to track the user's last selection, which prevents an unexpected user interaction.
Additionally, I had to make these work in multiplayer. Thankfully, I had worked on a previous project using PUN 2 (See DentaLab), so my knowledge carried over!
MultiplayerCanvasManager.cs.
using System.Collections; | |
using System.Collections.Generic; | |
using UnityEngine; | |
using Photon.Pun; | |
public classMultiplayerCanvasManager: MonoBehaviourPun, IPunObservable | |
{ | |
public GameObjectmodel,histology,video,info; | |
public GameObjecttoothModel; | |
public MeshRendererleft,right; | |
public Materialunselected; | |
string lastPressed=""; | |
void Start() | |
{ | |
model.SetActive(false); | |
histology.SetActive(false); | |
video.SetActive(false); | |
info.SetActive(false); | |
} | |
/* | |
* 3D MODEL | |
*/ | |
public voidSetModelActive() | |
{ | |
photonView.RPC("RPC_SetModelActive", RpcTarget.All); | |
} | |
[PunRPC] | |
voidRPC_SetModelActive() | |
{ | |
if (!(lastPressed.Equals("RootCanal")&& model.activeSelf)) | |
{ | |
model.SetActive(!model.activeSelf); | |
} | |
toothModel.SetActive(true); | |
lastPressed ="Model"; | |
} | |
/* | |
* HISTOLOGY | |
*/ | |
public voidSetHistologyActive() | |
{ | |
photonView.RPC("RPC_SetHistologyActive", RpcTarget.All); | |
} | |
[PunRPC] | |
voidRPC_SetHistologyActive() | |
{ | |
histology.SetActive(!histology.activeSelf); | |
lastPressed ="Histology"; | |
} | |
/* | |
* ROOT CANAL | |
*/ | |
public voidSetRootCanalActive() | |
{ | |
photonView.RPC("RPC_SetRootCanalActive", RpcTarget.All); | |
} | |
[PunRPC] | |
voidRPC_SetRootCanalActive() | |
{ | |
if (!(lastPressed.Equals("Model")&& model.activeSelf)) | |
{ | |
model.SetActive(!model.activeSelf); | |
} | |
toothModel.SetActive(false); | |
lastPressed ="RootCanal"; | |
} | |
/* | |
* VIDEO | |
*/ | |
public voidSetVideoActive() | |
{ | |
photonView.RPC("RPC_SetVideoActive", RpcTarget.All); | |
} | |
[PunRPC] | |
voidRPC_SetVideoActive() | |
{ | |
video.SetActive(!video.activeSelf); | |
lastPressed ="Video"; | |
} | |
/* | |
* INFO | |
*/ | |
public voidSetInfoActive() | |
{ | |
photonView.RPC("RPC_SetInfoActive", RpcTarget.All); | |
} | |
[PunRPC] | |
void RPC_SetInfoActive() | |
{ | |
info.SetActive(!info.activeSelf); | |
lastPressed ="Info"; | |
} | |
/* | |
* CLOSE | |
*/ | |
public voidCloseMenu() | |
{ | |
photonView.RPC("RPC_SetMenuActive", RpcTarget.All); | |
left.material =unselected; | |
right.material =unselected; | |
} | |
[PunRPC] | |
void RPC_SetMenuActive() | |
{ | |
gameObject.SetActive(!gameObject.activeSelf); | |
lastPressed ="Close"; | |
} | |
... | |
} |
MultiplayerToothManager.cs.
using System.Collections; | |
using System.Collections.Generic; | |
using UnityEngine; | |
using Photon.Pun; | |
public classMultiplayerToothManager: MonoBehaviourPun, IPunObservable | |
{ | |
[Header("Upper Teeth Menus")] | |
public GameObjectupperCentralIncisor; | |
public GameObjectupperLateralIncisor; | |
[Header("Upper Teeth Mesh Renderers")] | |
public MeshRendererupperCentralIncisorMRLeft; | |
public MeshRendererupperCentralIncisorMRRight; | |
... | |
[Header("Tooth Material")] | |
public Materialselected; | |
void Start() | |
{ | |
// Disable menus | |
lowerCentralIncisor.SetActive(false); | |
lowerLateralIncisor.SetActive(false); | |
lowerCanine.SetActive(false); | |
upperCentralIncisor.SetActive(false); | |
upperLateralIncisor.SetActive(false); | |
upperCanine.SetActive(false); | |
} | |
public voidSetMenuActive(string toothMenu) | |
{ | |
if (toothMenu.Equals("lowerCentralIncisor")) | |
{ | |
photonView.RPC("RPC_SetMandiCentralActive", RpcTarget.All); | |
} | |
else if(toothMenu.Equals("lowerLateralIncisor")) | |
{ | |
photonView.RPC("RPC_SetMandiLateralActive", RpcTarget.All); | |
} | |
else if(toothMenu.Equals("lowerCanine")) | |
{ | |
photonView.RPC("RPC_SetMandiCanineActive", RpcTarget.All); | |
} | |
else if(toothMenu.Equals("upperCentralIncisor")) | |
{ | |
photonView.RPC("RPC_SetMaxCentralActive", RpcTarget.All); | |
} | |
else if(toothMenu.Equals("upperLateralIncisor")) | |
{ | |
photonView.RPC("RPC_SetMaxLateralActive", RpcTarget.All); | |
} | |
else if(toothMenu.Equals("upperCanine")) | |
{ | |
photonView.RPC("RPC_SetMaxCanineActive", RpcTarget.All); | |
} | |
} | |
[PunRPC] | |
voidRPC_SetMaxCentralActive() | |
{ | |
upperCentralIncisor.SetActive(true); | |
upperCentralIncisorMRLeft.material = selected; | |
upperCentralIncisorMRRight.material = selected; | |
} | |
[PunRPC] | |
voidRPC_SetMaxLateralActive() | |
{ | |
upperLateralIncisor.SetActive(true); | |
upperLateralIncisorMRLeft.material = selected; | |
upperLateralIncisorMRRight.material = selected; | |
} | |
... | |
} |
Magnifying Glass.
Description.
A user can pickup the magnifying glass to view the root canal of the selected tooth. This allows for greater interactivity than the simple “Root Canal” option of the information panel.
Challenges.
Portals are tough enough in regular 3D; stereoscopic rendering makes them even harder to execute in VR. Thankfully, the “Multi Pass” Stereo Rendering Mode allowed these custom stencil shaders to be visible in both eyes.
RootCanal.shader.
Shader"Custom/RootCanal" | |
{ | |
Properties | |
{ | |
_Color("Main Color", Color) = (1,1,1,1) | |
_SpecColor("Specular Color", Color) = (0.5, 0.5, 0.5, 1) | |
_Shininess("Shininess", Range(0.03, 1)) = 0.078125 | |
_MainTex("Base (RGB) Gloss (A)", 2D) = "white" {} | |
_BumpMap("Normalmap", 2D) = "bump" {} | |
_StencilReferenceID("Stencil ID Reference", Float) = 1 | |
[Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp("Stencil Comparison", Float) = 3 | |
[Enum(UnityEngine.Rendering.StencilOp)] _StencilOp("Stencil Operation", Float) = 0 | |
_StencilWriteMask("Stencil Write Mask", Float) = 255 | |
_StencilReadMask("Stencil Read Mask", Float) = 255 | |
} | |
SubShader | |
{ | |
Tags | |
{ | |
"Queue" = "Geometry" | |
"RenderType" = "StencilOpaque" | |
} | |
LOD 400 | |
Stencil | |
{ | |
Ref 1 | |
Comp Equal | |
Pass Keep | |
ReadMask[_StencilReadMask] | |
WriteMask[_StencilWriteMask] | |
} | |
CGPROGRAM | |
#include"UnityCG.cginc" | |
#pragma surface surf BlinnPhong | |
sampler2D _MainTex; | |
sampler2D _BumpMap; | |
fixed4 _Color; | |
half _Shininess; | |
struct Input | |
{ | |
float2 uv_MainTex; | |
float2 uv_BumpMap; | |
}; | |
void surf(Input IN, inoutSurfaceOutput o) | |
{ | |
fixed4 tex = tex2D(_MainTex, IN.uv_MainTex); | |
o.Albedo = tex.rgb * _Color.rgb; | |
o.Gloss = tex.a; | |
o.Alpha = tex.a * _Color.a; | |
o.Specular = _Shininess; | |
o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap)); | |
} | |
ENDCG | |
} | |
FallBack "Specular" | |
} |
Portal.shader.
Shader "Custom/Portal" | |
{ | |
Properties | |
{ | |
_Color("Main Color", Color) = (1,1,1,1) | |
_StencilReferenceID("Stencil ID Reference Reference", Float) = 1 | |
[Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp("Stencil Comparison", Float) = 8 | |
[Enum(UnityEngine.Rendering.StencilOp)] _StencilOp("Stencil Operation", Float) = 2 | |
_StencilWriteMask("Stencil Write Mask", Float) = 255 | |
_StencilReadMask("Stencil Read Mask", Float) = 255 | |
[Enum(UnityEngine.Rendering.Shader_ColorWriteMask)] _ColorMask("Color Mask", Float) = 0 | |
[MaterialToggle] _ZWrite("ZWrite", Float) = 0 | |
} | |
SubShader | |
{ | |
Tags | |
{ | |
"RenderType" = "StencilMaskOpaque" | |
"Queue" = "Geometry-100" | |
"IgnoreProjector" = "True" | |
} | |
Pass | |
{ | |
ZWrite[_ZWrite] | |
ColorMask[_ColorMask] | |
Stencil | |
{ | |
Ref 1 | |
Comp Always | |
Pass Replace | |
ReadMask[_StencilReadMask] | |
WriteMask[_StencilWriteMask] | |
} | |
CGPROGRAM | |
#include"UnityCG.cginc" | |
#pragma vertex vert | |
#pragma fragment frag | |
fixed4 _Color; | |
struct appdata | |
{ | |
float4 vertex : POSITION; | |
}; | |
struct v2f | |
{ | |
float4 pos : SV_POSITION; | |
}; | |
v2f vert(appdata v) | |
{ | |
v2f o; | |
o.pos = UnityObjectToClipPos(v.vertex); | |
return o; | |
} | |
half4 frag(v2f i) : COLOR | |
{ | |
return _Color; | |
} | |
ENDCG | |
} | |
} | |
} |