Skip to content

Environment Maps and Realistic Render

A Three.js guide for coding agents. Also covers environment map, envMap, scene.environment, scene.background, cube texture, CubeTextureLoader, and 27 more.

Show all 33 aliases

environment map, envMap, scene.environment, scene.background, cube texture, CubeTextureLoader, HDRI, RGBELoader, EXRLoader, equirectangular, EquirectangularReflectionMapping, environmentIntensity, envMapIntensity, backgroundBlurriness, backgroundIntensity, background rotation, GroundedSkybox, ground projected skybox, objects look like they are flying, real-time environment map, WebGLCubeRenderTarget, CubeCamera, layers, tone mapping, ACESFilmicToneMapping, toneMappingExposure, antialias, aliasing jagged edges, shadow acne, normalBias, realistic render, model looks black, texture looks washed out white

What an environment map does

  • One image of the surroundings serves two jobs: scene.background (what you see behind objects) and scene.environment (lighting and reflections on every MeshStandardMaterial in the scene)
  • Environment lighting alone can light a whole scene realistically with zero light objects. A glTF model that renders black has standard materials and no light: assign scene.environment (or add lights) and it appears
  • Set both when you want the object to visibly sit in the pictured world; set only environment when you just want the lighting (studio HDRIs are often ugly as backgrounds but great as light)

Source formats

LDR cube texture

Six images (cube faces) loaded with CubeTextureLoader, in this exact order: px, nx, py, ny, pz, nz.

const cubeTextureLoader = new THREE.CubeTextureLoader()
const environmentMap = cubeTextureLoader.load([
    '/environmentMaps/0/px.png', '/environmentMaps/0/nx.png',
    '/environmentMaps/0/py.png', '/environmentMaps/0/ny.png',
    '/environmentMaps/0/pz.png', '/environmentMaps/0/nz.png'
])
scene.environment = environmentMap
scene.background = environmentMap
  • A cube texture needs no mapping assignment; it is used as-is
  • Do not build a giant inverted cube mesh for the background; scene.background handles it

HDR equirectangular (.hdr)

  • HDR stores luminosity beyond 0..1: a sun and a lamp both look white in a PNG but carry different brightness in HDR, which is what makes the lighting realistic and contrasty
  • Equirectangular means one 360-degree image with stretched poles. HDR does not have to be equirectangular, but usually is
  • .hdr uses RGBE encoding (Red Green Blue Exponent), hence RGBELoader not "HDRLoader":
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js'

const rgbeLoader = new RGBELoader()
rgbeLoader.load('/environmentMaps/0/2k.hdr', (environmentMap) =>
{
    environmentMap.mapping = THREE.EquirectangularReflectionMapping
    scene.background = environmentMap
    scene.environment = environmentMap
})
  • Pitfall: forgetting mapping = THREE.EquirectangularReflectionMapping on any equirectangular texture gives a broken smeared background
  • Downside: HDR files are heavy to load and render. Mitigate with a lower resolution plus backgroundBlurriness, or skip the background entirely and go very low-res for lighting only

EXR (.exr)

  • Also high dynamic range, different encoding; supports layers and an alpha channel. Same usage, different loader:
import { EXRLoader } from 'three/addons/loaders/EXRLoader.js'
const exrLoader = new EXRLoader()
exrLoader.load('/environmentMaps/map.exr', (environmentMap) =>
{
    environmentMap.mapping = THREE.EquirectangularReflectionMapping
    scene.background = environmentMap
    scene.environment = environmentMap
})

LDR equirectangular (plain jpg or png)

  • AI skybox generators (Blockade Labs Skybox Lab and similar) output LDR equirectangular jpgs. Load with the plain TextureLoader, and because it is a color image, declare sRGB:
const environmentMap = textureLoader.load('/environmentMaps/skybox.jpg')
environmentMap.mapping = THREE.EquirectangularReflectionMapping
environmentMap.colorSpace = THREE.SRGBColorSpace
scene.background = environmentMap
scene.environment = environmentMap
  • LDR carries less light energy, so bump scene.environmentIntensity (4 is a reasonable start)

Making your own

  • Sources: polyhaven.com HDRIs; convert HDRI to cube faces with matheowis.github.io/HDRI-to-CubeMap
  • Blender: Cycles, black world background, camera at origin rotated 90 degrees on X with Lens type Panoramic > Equirectangular, output 2048x1024 (power of two is good practice), area lights with Camera visibility checked, render with F12, save as Radiance HDR. A "boring" render of two or three colored area lights makes an excellent studio lighting rig in Three.js
  • Keep detail away from the top and bottom of an equirectangular canvas; those pixels are directly above and below the camera and get stretched

Scene tweaks

scene.environmentIntensity = 1      // strength of env lighting on materials
scene.backgroundBlurriness = 0      // 0..1, blur background only
scene.backgroundIntensity = 1       // background brightness only
scene.backgroundRotation.y = 0      // Euler
scene.environmentRotation.y = 0     // Euler, independent of background
  • environmentIntensity scales the lighting contribution; backgroundIntensity scales only the visible backdrop. They are independent, do not confuse them
  • backgroundBlurriness (around 0.2) hides a low-resolution map and pushes focus to the foreground object
  • Rotations are full Eulers, but for realistic scenes rotate only y so the floor stays down
  • Wire all of these into lil-gui while tuning; they are the main dials of the look

Ground projected environment map

  • Problem: the environment is infinitely far away, so objects appear to float above the pictured ground
  • Fix: GroundedSkybox, a sphere with a flattened bottom that the texture is projected onto
import { GroundedSkybox } from 'three/addons/objects/GroundedSkybox.js'

rgbeLoader.load('/environmentMaps/2/2k.hdr', (environmentMap) =>
{
    environmentMap.mapping = THREE.EquirectangularReflectionMapping
    scene.environment = environmentMap // lighting only, skybox is the visible background

    const skybox = new GroundedSkybox(environmentMap, 15, 70) // texture, height, radius
    skybox.position.y = 15 // raise by the height so the flat bottom sits at y = 0
    scene.add(skybox)
})
  • Pitfall: without position.y = height the flattened bottom sits below the floor. Debug with skybox.material.wireframe = true
  • Tune height and radius per map. The trick fails when objects in the source image sit near the projection center

Real-time environment map

Render your own scene into a cube texture every frame so emissive objects light and reflect onto everything else.

const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(256, { type: THREE.HalfFloatType })
scene.environment = cubeRenderTarget.texture

const cubeCamera = new THREE.CubeCamera(0.1, 100, cubeRenderTarget)
cubeCamera.layers.set(1)

// an emissive object: color beyond 0..1 acts like an HDR light source
const holyDonut = new THREE.Mesh(
    new THREE.TorusGeometry(8, 0.5),
    new THREE.MeshBasicMaterial({ color: new THREE.Color(10, 4, 2) })
)
holyDonut.layers.enable(1)
scene.add(holyDonut)

// in tick
holyDonut.rotation.x = Math.sin(elapsedTime) * 2
cubeCamera.update(renderer, scene)
  • HalfFloatType (16-bit) over FloatType (32-bit): the visual difference is negligible and it halves memory
  • Because the target is high range, a MeshBasicMaterial color like (10, 4, 2) genuinely emits light into the environment
  • Layers fix the self-occlusion bug where every scene object ends up baked into the env map blocking light. Layers are categories on any Object3D: layers.enable(n) adds, layers.disable(n) removes, layers.set(n) replaces all. A camera only sees objects sharing one of its layers; everything defaults to layer 0. cubeCamera.layers.set(1) plus holyDonut.layers.enable(1) means the cube camera sees only the donut while the main camera (layer 0) still sees everything
  • Costs 6 renders per frame. Keep the render target small (256), keep the env-rendered scene minimal, watch the frame rate
  • Gotchas: layers get confusing fast, and lights are NOT affected by layers

Realistic render checklist

Applies whenever a loaded model should look real. Each item is a renderer or scene setting, not a material hack.

1. Tone mapping

Converts HDR values to the LDR your screen can show; in Three.js it also convincingly fakes the filmic look on LDR content.

renderer.toneMapping = THREE.ACESFilmicToneMapping // or Reinhard, Cineon, Linear, NoToneMapping (default)
renderer.toneMappingExposure = 3
  • ACESFilmic is the usual realistic pick; Reinhard reads washed out but mimics an imperfect camera
  • toneMappingExposure is the light-in dial; expose both in lil-gui (a dropdown object of names to constants for toneMapping)

2. Antialiasing

  • Aliasing is the stair-step artifact on geometry edges. Options: super sampling (SSAA, render at 2x, 4x the pixels, expensive) or multi sampling (MSAA, extra samples only on edges)
  • Three.js sets up MSAA for you, but only at construction time:
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
  • Setting antialias after instantiation does nothing
  • Screens with pixel ratio 2 or more barely need it; ideally enable it only below pixel ratio 2

3. Shadows that match the environment

  • An environment map lights from everywhere and cannot cast shadows. Add one DirectionalLight whose position and intensity roughly match the map's main light source
const directionalLight = new THREE.DirectionalLight('#ffffff', 6)
directionalLight.position.set(-4, 6.5, 2.5)
scene.add(directionalLight)

renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
directionalLight.castShadow = true
directionalLight.shadow.camera.far = 15
directionalLight.shadow.mapSize.set(1024, 1024)
  • Aim the light with directionalLight.target.position.set(0, 4, 0); then call directionalLight.target.updateWorldMatrix() (or add the target to the scene). The target is an Object3D that is not in the scene, so its matrix never updates on its own and the position change silently does nothing
  • Use a CameraHelper on directionalLight.shadow.camera while framing, then remove it. Tighten far to the scene
  • 1024 shadow maps are affordable with one light; 512 gives a blurrier shadow that often still looks fine and is cheaper
  • Enable shadows on every mesh of the model by traversing:
scene.traverse((child) =>
{
    if(child.isMesh)
    {
        child.castShadow = true
        child.receiveShadow = true
    }
})

4. Shadow acne

  • Stripe artifacts across a model's own surface: the mesh is shadowing itself due to depth precision. Most visible with environmentIntensity at 0
  • Fix with the light's shadow bias values, tuned in lil-gui:
directionalLight.shadow.normalBias = 0.027 // helps rounded surfaces
directionalLight.shadow.bias = -0.004      // helps flat surfaces

5. Color space on textures

  • Color textures meant to be seen (map) are sRGB encoded and must be declared: texture.colorSpace = THREE.SRGBColorSpace. Otherwise they render oddly white and washed out
  • Data textures (normal, roughness, metalness, AO) stay linear; do not mark them sRGB
  • glTF models handle this for you; the color space is declared inside the file. Manually loaded textures do not

6. Textures for floors and walls

  • Ground the model with real PBR texture sets (Poly Haven textures). One MeshStandardMaterial can share a combined ARM texture for three slots:
const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(8, 8),
    new THREE.MeshStandardMaterial({
        map: floorColorTexture,          // sRGB
        normalMap: floorNormalTexture,   // linear
        aoMap: floorARMTexture,          // one arm texture reused for
        roughnessMap: floorARMTexture,   // AO + roughness + metalness
        metalnessMap: floorARMTexture,
    })
)
floor.rotation.x = -Math.PI * 0.5

7. Judge the result

  • Tune with lil-gui, compare against real-life references, step away from the screen, get an outside opinion. Realism is a calibration loop, not a single setting
  • HDR maps and big textures were used here freely for realism; production work still has to watch load size and frame rate. Effects like bloom, depth of field, and ambient occlusion come from post-processing

Common mistakes

  • Model renders black: standard materials with no lights and no scene.environment
  • Assigning an equirectangular texture without EquirectangularReflectionMapping
  • Wrong cube face order (must be px, nx, py, ny, pz, nz)
  • Confusing backgroundIntensity (backdrop only) with environmentIntensity (lighting)
  • LDR jpg env map without SRGBColorSpace, or without bumping environmentIntensity
  • Rotating the environment on x or z and ending up with the floor sideways
  • GroundedSkybox not raised by its height, so the ground plane is buried
  • Real-time env map without layers, baking every scene object into the lighting
  • Oversized WebGLCubeRenderTarget or a busy env scene tanking the frame rate (6 renders per frame)
  • Setting antialias after the renderer is constructed
  • Moving directionalLight.target.position without updateWorldMatrix() or adding the target to the scene
  • Ignoring shadow acne, or trying to fix it with light position instead of bias and normalBias
  • Marking normal, roughness, or AO maps as sRGB

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "environment-maps-and-realistic-render" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems