After working as an architect for two years, I decided to switch my career path to become a software engineer. Questopia is a 3D visualization of my room and an online portfolio for two groups of work:
- Current projects as a programmer, from 2022 to the present
- Previous projects as an architectural designer, from 2018 to 2021
Visit the website to discover more.
- React
- TypeScript
- Three.js
- React Three Fiber
- Drei
- GLSL shaders
- Blender
- Leva
- Vite
- vite-plugin-glsl
- Vercel
- Interactive 3D room built with React Three Fiber
- Baked day and night room textures mixed through a custom shader
- Smooth orbit, pan, and zoom camera controls
- Clickable 3D screens and images that open portfolio overlays
- Project data separated from the UI in
src/data/projects.ts - Loading progress screen powered by Drei asset loading state
- Draco and Basis assets served from
public/
src/
App.tsx # App state, loading screen, overlays
components/
Experience.tsx # R3F Canvas and 3D scene composition
CameraRig.tsx # Custom camera orbit, pan, and zoom controls
Room.tsx # Main baked room model and shader material
Screens.tsx # Interactive current-project screens
Images.tsx # Interactive previous-project image wall
InteractionRaycaster.tsx # Mesh-name based click fallback
Overlay.tsx # Project grid overlay
LoadingScreen.tsx # Loading progress UI
data/
projects.ts # Current and previous portfolio projects
shaders/
baked/ # Room shader
coffeeSteam/ # Coffee steam shader
public/
assets/ # GLB models and baked textures
imgs/ # Project covers and contact images
draco/ # Draco decoder assets
basis/ # Basis transcoder assetsThe project was refactored from a custom Three.js singleton architecture to a React component architecture powered by React Three Fiber.
App.tsx owns the application state that sits outside the WebGL scene: overlay visibility, loading progress, and loading completion. The 3D experience reports loading progress through callbacks, and the overlay is rendered as normal React UI on top of the canvas.
<Suspense fallback={null}>
<Experience
onOpenOverlay={handleOpenOverlay}
onProgress={handleProgress}
onReady={handleReady}
/>
</Suspense>
<LoadingScreen progress={progress} ready={ready} />
<Overlay kind={overlayOpen} onClose={handleCloseOverlay} />Experience.tsx owns the <Canvas /> and composes the scene from small components. Models, shaders, camera behavior, click handling, and UI labels are now isolated by responsibility.
<Canvas
camera={{ fov: 25, near: 0.1, far: 150, position: [0, 0, 5] }}
gl={{ antialias: true, outputColorSpace: 'srgb' }}
dpr={[1, dpr]}
frameloop="always"
>
<CameraRig />
<Room />
<Title />
<ArchiModels />
<Screens onOpenOverlay={onOpenOverlay} />
<Images onOpenOverlay={onOpenOverlay} />
<InteractionRaycaster onOpenOverlay={onOpenOverlay} />
<RotatingChair />
<CoffeeSteam />
<InteractiveObjects />
<HoverLabels />
<Preload all />
</Canvas>The camera movement is limited, so some faces can never be seen, including the back side of books, the bottom of the bed, and the bottom side of objects on the table.
After deleting redundant and overlapping faces, the model size went from 18 MB to 3 MB, an 88% reduction.
There is no dynamic lighting or ray tracing in the room. Most colors and shadows are pre-rendered as baked textures. This keeps the scene lightweight and also makes it possible to mix day and night states through shader uniforms.
In Blender, export the scene as a .glb or .gltf file. Important settings:
Material: no export
FlipY: enabled
Geometry:
UVs: enabled
Compression: enabled, unless it causes loading issuesModels and textures are loaded with Drei hooks. Each component is responsible for its own GLB, texture, material, and scene clone.
const { scene } = useGLTF('/assets/imagesModel.glb')
const texture = useTexture('/assets/imagesDayTexture.jpg')
texture.flipY = false
texture.colorSpace = THREE.SRGBColorSpace
const material = useMemo(
() => new THREE.MeshBasicMaterial({ map: texture }),
[texture],
)
const clonedScene = useMemo(() => {
const cloned = scene.clone(true)
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = material
}
})
return cloned
}, [scene, material])The room uses a custom shader to mix baked day and night textures, then add light-map based screen, lamp, and shelf colors.
const material = new THREE.ShaderMaterial({
uniforms: {
uBakedDayTexture: { value: bakedDayTexture },
uBakedNightTexture: { value: bakedNightTexture },
uLightMapTexture: { value: lightMapTexture },
uNightMix: { value: debugValues.nightMix },
uLightScreenColor: { value: new THREE.Color(debugValues.screenColor) },
uLightScreenStrength: { value: debugValues.screenStrength },
uLightLampColor: { value: new THREE.Color(debugValues.lampColor) },
uLightLampStrength: { value: debugValues.lampStrength },
uLightShelfColor: { value: new THREE.Color(debugValues.shelfColor) },
uLightShelfStrength: { value: debugValues.shelfStrength },
},
vertexShader,
fragmentShader,
})The shader receives those uniforms in src/shaders/baked/fragment.glsl.
uniform sampler2D uBakedDayTexture;
uniform sampler2D uBakedNightTexture;
uniform sampler2D uLightMapTexture;
uniform float uNightMix;
uniform vec3 uLightScreenColor;
uniform float uLightScreenStrength;
uniform vec3 uLightLampColor;
uniform float uLightLampStrength;
uniform vec3 uLightShelfColor;
uniform float uLightShelfStrength;
varying vec2 vUv;
// blendLighten helper omitted for brevity.
void main()
{
vec3 bakedDayColor = texture2D(uBakedDayTexture, vUv).rgb;
vec3 bakedNightColor = texture2D(uBakedNightTexture, vUv).rgb;
vec3 bakedColor = mix(bakedDayColor, bakedNightColor, uNightMix);
vec3 lightMapColor = texture2D(uLightMapTexture, vUv).rgb;
float lightScreenStrength = lightMapColor.r * uLightScreenStrength;
bakedColor = blendLighten(bakedColor, uLightScreenColor, lightScreenStrength);
float lightShelfStrength = lightMapColor.b * uLightShelfStrength;
bakedColor = blendLighten(bakedColor, uLightShelfColor, lightShelfStrength);
float lightLampStrength = lightMapColor.g * uLightLampStrength;
bakedColor = blendLighten(bakedColor, uLightLampColor, lightLampStrength);
gl_FragColor = vec4(bakedColor, 1.0);
}The primary click behavior is attached to the relevant R3F primitives in Screens.tsx and Images.tsx. InteractionRaycaster.tsx also keeps a mesh-name based raycaster fallback for cases where a cloned GLB mesh needs explicit hit testing.
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(pointer, camera)
const [hit] = raycaster.intersectObjects(targets, false)
if (hit && SCREEN_MESH_NAMES.has(hit.object.name)) {
onOpenOverlay('current')
}CameraRig.tsx implements custom camera controls instead of using OrbitControls. It supports:
- Drag to rotate
- Shift, Ctrl, middle mouse, or right mouse drag to pan
- Wheel to zoom
- Touch drag and two-finger alternative movement
- Clamped camera radius, angle, and target position
- Smoothed movement in
useFrame
Clone the project:
git clone git@github.com:GlintonLiao/questopia.git
cd questopiaInstall dependencies:
npm installStart the development server:
npm run devOpen http://localhost:5173 in your browser.
Build for production:
npm run buildPreview the production build:
npm run previewIf you want to build your own Three.js project with a similar TypeScript and Vite setup, you can reference threejs-template-typescript.

