Three.js From Zero · Article s13-07

R3F Portal Scenes

R3F Portal Scenes is Article s13-07 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS13-07 · R3F Mastery

Season 13 · Article 07 · R3F Mastery

A doorway you can see another world through. Two scenes, one render. MeshPortalMaterial from drei renders a separate sub-scene onto the surface of a mesh — perfect for room transitions, dimensional doorways, picture-in-picture.

Code-walkthrough article

Requires drei. Visual effect best appreciated in a real project — copy the snippet, drop in your scene.

The trick: render targets

Under the hood, drei's MeshPortalMaterial renders a separate Three.js scene to an off-screen render target, then uses that texture on your mesh's surface. The portal mesh effectively becomes a window — what's "inside" is the other scene from the other scene's camera.

Basic portal

import { MeshPortalMaterial, Environment } from '@react-three/drei';

function PortalScene() {
  return (
    <mesh>
      <planeGeometry args={[2, 3]} />
      <MeshPortalMaterial>
        {/* This is the OTHER world, rendered onto the plane */}
        <Environment preset="sunset" background />
        <mesh>
          <sphereGeometry />
          <meshStandardMaterial color="orange" />
        </mesh>
      </MeshPortalMaterial>
    </mesh>
  );
}

Everything inside <MeshPortalMaterial> is its own scene with its own background, lighting, and camera. From the outside, the mesh looks like it has a sunset behind it and an orange sphere in front. From elsewhere in your main scene, the plane is opaque — you see the portal content.

Camera dolly-in transition

const [active, setActive] = useState(false);

<MeshPortalMaterial blend={active ? 0 : 1}>
  {/* portal contents */}
</MeshPortalMaterial>

blend 0 = camera is fully inside the portal world (you're "through" the doorway). blend 1 = camera is in your main world, looking AT the portal. Animate this value with framer-motion or react-spring for a "step into the world" effect.

Multiple portals with shared rendering

function Gallery() {
  return (
    <>
      {worlds.map((world, i) => (
        <mesh key={world.id} position={[i * 3, 0, 0]}>
          <circleGeometry args={[1, 32]} />
          <MeshPortalMaterial worldUnits={false}>
            <color attach="background" args={[world.bg]} />
            <world.Scene />
          </MeshPortalMaterial>
        </mesh>
      ))}
    </>
  );
}

A row of portals, each showing a different world. The art-gallery / dimensional-rift pattern. Each renders to its own RT — cost scales linearly with portal count, so don't put 50 of them at once.

Performance considerations

A portal is a second render pass. If your portal scene is as complex as your main scene, you've doubled rendering cost. Tips:

  • Use simpler geometry in portal scenes (fewer lights, no shadows).
  • Render portals at lower resolution: <MeshPortalMaterial resolution={512}> defaults to 1024.
  • Cull portals that are off-screen — drei does this automatically when frames={1} is set.

Common first-time pitfalls

"Portal is solid black." No light inside the portal scene. The portal world is separate — it needs its own <ambientLight> / <Environment>.
"FPS halves when I add a portal." Expected — every portal is a second render. Drop portal resolution to 256 or 512.
"Portal contents look wrong-scaled." Set worldUnits appropriately. true = treat the portal plane as a real opening (camera moves through it geometrically). false = treat portal plane as a 2D window (more like a TV screen).

Exercises

  1. The classic dimensional door. A doorway in a wall, looking out onto a sunny field. Approach the door — camera dolly forward — and animate blend to 0 to "enter."
  2. Mini-map portal. A circular portal at the top-right corner showing a top-down camera of the main scene. Two cameras, one render target.
  3. Aquarium. A glass tank (cube) with fish inside, rendered via portal so the fish are confined to the tank world. Looks impossible — that's why it works.