VR environment design

Build a readable, grounded WebXR environment with lighting, materials, atmosphere and a sensible scale.

By pavlin-dev

Use this skill

Three.js and TypeScript; headset support varies by WebXR browser.

Add the skill folder in Settings. The in-app library is coming in the next desktop update.

The skill

The source instructions, with their original wording. Read as Markdown.

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-floor so 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.