Environment scaffold — a world that looks good and grounds the player
A bare three.js scene (grey background, default light) looks bad and, in VR, feels disorienting — there's no floor, no sense of scale. A few deliberate choices give the player a place to stand: a floor at their feet, a sky around them, light that reveals shape, and an intentional palette. The catch is that VR doubles every rendering cost (two eyes) and punishes a few effects that look fine on a monitor, so the defaults here are tuned for the headset.
Core principle: ground the player (floor + sky + 1:1 scale), light for shape, and keep the per-eye cost low.
When to use
- Setting up or refining the look of a VR environment.
- Symptoms: scene looks flat/washed-out, no floor or horizon, player can't gauge scale, frame rate drops when you "make it pretty".
Renderer creation itself (XR enable, render loop, tone mapping/color space) lives in xr-session-setup — set those there. This skill is the contents of the world.
Ground the player first
The single biggest comfort win is a visible floor at the real floor height. With a local-floor reference space (xr-reference-space), y=0 is the physical floor — put the ground there.
import * as THREE from 'three';
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(50, 50),
new THREE.MeshStandardMaterial({ color: 0x3a3f4b, roughness: 0.9, metalness: 0 }),
);
ground.rotation.x = -Math.PI / 2; // lay flat
ground.position.y = 0; // physical floor with local-floor reference space
ground.receiveShadow = true;
scene.add(ground);
// A subtle grid helps players judge distance and scale — cheap and effective in VR.
scene.add(new THREE.GridHelper(50, 50, 0x556070, 0x2a2f38));
Build a quick scale reference while prototyping: a 1.7 m tall box reads as "person height" — if it looks wrong, your units are wrong.
Light for shape — three lights
// Ambient: lifts shadows so nothing is pure black (which reads as a hole in VR).
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
// Key directional ("sun"): creates form and (optionally) shadows.
const sun = new THREE.DirectionalLight(0xffffff, 1.0);
sun.position.set(5, 10, 5);
sun.castShadow = true; // see the shadow note below before enabling
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);
// Hemisphere: sky/ground tint — dramatically improves stylized/low-poly looks.
scene.add(new THREE.HemisphereLight(0x88aaff, 0x4a3f33, 0.5));
Shadows in VR: real-time shadows render twice (once per eye) and are a common frame-rate killer. Enable only one shadow-casting light, keep the shadow map at 1024, tighten the shadow camera frustum, and prefer baked lighting for static geometry (lighting-and-baking). For fast-paced games, skip dynamic shadows entirely.
Sky and atmosphere
A surrounding sky removes the "floating in void" feeling. Cheapest is a background color; a gradient or skybox is better.
scene.background = new THREE.Color(0x0e1018); // pick from your palette
// Fog adds depth and hides geometry pop-in — but use it CAREFULLY in VR.
scene.fog = new THREE.Fog(0x0e1018, 15, 60); // match color to background
Fog caveat: mild distance fog is fine and helps depth, but heavy or fast-changing fog reduces the visual cues the vestibular system relies on and can worsen discomfort during locomotion. Keep it subtle, static, and matched to the background color.
Materials — pick one family
// Stylized PBR — physically lit, low roughness reads "premium".
const mat = new THREE.MeshStandardMaterial({ color: 0x55aaff, roughness: 0.6, metalness: 0 });
// Toon — flat illustrated look; needs a directional light to step-shade.
const toon = new THREE.MeshToonMaterial({ color: 0xff5577 });
Use one material family across the scene and a palette of 4-6 intentional colors. Mixing shading models looks incoherent. Use low-segment geometry (sphere 16×12, not 32×32) — it keeps a clean stylized look and halves vertex cost, which matters at 2× eyes.
A note on post-processing
The desktop "just add bloom" reflex is risky in VR. The standard EffectComposer path renders to a full-screen buffer and does not natively respect the stereo/multiview render targets — naïvely adding passes can break or double the cost. If you want glow, prefer emissive materials read directly or a VR-aware post pipeline, and always profile in-headset (vr-performance-budget). When in doubt, skip post-processing in VR.
Domain principles honored here
- Room-scale & 1:1 scale — floor at physical height, scale references in meters.
- Performance is comfort — low-poly, one shadow light (or baked), capped effects to protect the per-eye frame budget.
- Comfort first — a grounded floor/horizon and only-subtle fog avoid disorientation.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| No floor / floor not at y=0 | Player feels they're floating; scale unclear | ground plane at physical floor (local-floor) |
| Shadows on every light | Frame rate tanks (2× eyes) | one shadow light, or bake (lighting-and-baking) |
| Heavy/animated fog | Discomfort during movement | subtle, static fog matched to background |
| Adding bloom via default EffectComposer | Broken stereo or halved FPS | emissive-only, or a VR-aware pipeline; profile first |
| Mixed material families / random colors | Incoherent, cheap look | one family, 4-6 color palette |
| High-segment geometry everywhere | Vertex cost doubled across eyes | low segment counts |
Verify
- On-device: there's a visible floor at the player's real feet and a sky/horizon around them — no void.
- A 1.7 m reference object reads as roughly person-height (units are correct).
- Frame rate holds at the device refresh rate with the lighting in place (
vr-performance-budget). - At most one shadow-casting light (or shadows baked); no per-eye frame drop from shadows.
- One material family and a small intentional palette across the scene.
References
xr-reference-space—local-floorso y=0 is the real floor, and 1:1 scale.lighting-and-baking— baked lightmaps for static geometry instead of real-time shadows.vr-performance-budget— confirming the look stays within the per-eye budget.