threlte logo

CSS2DRenderer Overlay

This example shows how to run an additional Three.js renderer in parallel with Threlte’s <Canvas>, while still leveraging Threlte’s built-in elements. Specifically, we’ll run CSS2DRenderer to add flat labels to objects in a three-dimensional scene.

This example can be easily adapted to use CSS3DRenderer instead, if you want the elements to live “inside” the scene, rather than flat across the surface.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
</script>

<div id="css-renderer-target" />

<div id="main">
  <Canvas>
    <Scene />
  </Canvas>
</div>

<style>
  div#main {
    height: 100%;
  }
  #css-renderer-target {
    left: 0;
    position: absolute;
    pointer-events: none;
    top: 0;
  }
</style>
<script>
  import { T } from '@threlte/core'

  // A normal html svelte component, no relation the Threlte
  export let label = ''
  let count = 0

  function click() {
    count++
  }
</script>

<button on:click={click}>
  {label} - {count}
</button>

<style>
  button {
    margin-left: 0.75rem;
    border-radius: 0.25rem;
    padding: 0.25rem 0.5rem;
    pointer-events: auto;

    background-image: linear-gradient(#5e6cf4 0%, #1338db 100%);
  }
</style>
<script>
  import { T } from '@threlte/core'
  import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'

  export let pointerEvents = false

  let element
</script>

<div
  bind:this={element}
  style:pointer-events={pointerEvents ? 'auto' : 'none !important'}
  style:will-change="transform"
>
  <slot />
</div>

{#if element}
  <T
    {...$$restProps}
    is={CSS2DObject}
    args={[element]}
    let:ref
  >
    <slot
      name="three"
      {ref}
    />
  </T>
{/if}
<script lang="ts">
  import { T, useStage, useTask, useThrelte } from '@threlte/core'
  import { OrbitControls } from '@threlte/extras'
  import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js'
  import CounterLabel from './CounterLabel.svelte'
  import CssObject from './CssObject.svelte'

  const { scene, size, autoRenderTask, camera } = useThrelte()

  // Set up the CSS2DRenderer to run in a div placed atop the <Canvas>
  const element = document.querySelector('#css-renderer-target') as HTMLElement
  const cssRenderer = new CSS2DRenderer({ element })
  $: cssRenderer.setSize($size.width, $size.height)

  // We are running two renderers, and don't want to run
  // updateMatrixWorld twice; tell the renderers that we'll handle
  // it manually.
  // https://threejs.org/docs/#api/en/core/Object3D.updateWorldMatrix
  scene.matrixWorldAutoUpdate = false

  // To update the matrices *once* per frame, we'll use a task that is added
  // right before the autoRenderTask. This way, we can be sure that the
  // matrices are updated before the renderers run.
  useTask(
    () => {
      scene.updateMatrixWorld()
    },
    { before: autoRenderTask }
  )

  // The CSS2DRenderer needs to be updated after the autoRenderTask, so we
  // add a task that runs after it.
  useTask(
    () => {
      // Update the DOM
      cssRenderer.render(scene, camera.current)
    },
    {
      after: autoRenderTask,
      autoInvalidate: false
    }
  )
</script>

<T.PerspectiveCamera
  makeDefault
  position={[5, 5, 5]}
>
  <OrbitControls enableDamping />
</T.PerspectiveCamera>

<T.DirectionalLight position={[0, 10, 10]} />

<T.Mesh position.y={1}>
  <T.BoxGeometry args={[2, 2, 2]} />
  <T.MeshStandardMaterial color="#F64F6F" />
</T.Mesh>

<CssObject
  position={[-1, 2, 1]}
  center={[0, 0.5]}
>
  <CounterLabel label="Hello" />

  <T.Mesh slot="three">
    <T.SphereGeometry args={[0.25]} />
    <T.MeshStandardMaterial color="#4F6FF6" />
  </T.Mesh>
</CssObject>

<CssObject
  position={[1, 2, 1]}
  center={[0, 0.5]}
>
  <CounterLabel label="CSS" />

  <T.Mesh slot="three">
    <T.SphereGeometry args={[0.25]} />
    <T.MeshStandardMaterial color="#6FF64F" />
  </T.Mesh>
</CssObject>

<CssObject
  position={[1, 2, -1]}
  center={[0, 0.5]}
>
  <CounterLabel label="Renderer" />

  <T.Mesh slot="three">
    <T.SphereGeometry args={[0.25]} />
    <T.MeshStandardMaterial color="#F64F6F" />
  </T.Mesh>
</CssObject>

How does it work?

In this scene, we run two renderers - the default one provided by Threlte, and a new CSS2DRenderer which we initialize manually. Threlte’s renderer runs on a canvas element as usual, while our new renderer runs in a <div> with absolute positioning on top of it.

The render loop

To integrate the a new renderer into svelte’s loop, we call it inside a task added right after Threlte’s autoRenderTask. For details on how to use the Threlte Task Scheduling System, see the documentation.

By default, each renderer traverses the scene and updates every object. We can set scene.matrixWorldAutoUpdate to false and manually call scene.updateMatrixWorld() each tick in order to avoid duplicating the work, since we’re running two renderers. To do that, we’re adding a task that runs right before Threlte’s autoRenderTask.

Scene.svelte
<script>
  const { scene, size, autoRenderTask, camera } = useThrelte()

  // Set up the CSS2DRenderer to run in a div placed atop the <Canvas>
  const element = document.querySelector('#css-renderer-target') as HTMLElement
  const cssRenderer = new CSS2DRenderer({ element })
  $: cssRenderer.setSize($size.width, $size.height)

  // We are running two renderers, and don't want to run
  // updateMatrixWorld twice; tell the renderers that we'll handle
  // it manually.
  // https://threejs.org/docs/#api/en/core/Object3D.updateWorldMatrix
  scene.matrixWorldAutoUpdate = false

  // To update the matrices *once* per frame, we'll use a task that is added
  // right before the autoRenderTask. This way, we can be sure that the
  // matrices are updated before the renderers run.
  useTask(
    () => {
      scene.updateMatrixWorld()
    },
    { before: autoRenderTask }
  )

  // The CSS2DRenderer needs to be updated after the autoRenderTask, so we
  // add a task that runs after it.
  useTask(
    () => {
      // Update the DOM
      cssRenderer.render(scene, camera.current)
    },
    {
      after: autoRenderTask,
      autoInvalidate: false
    }
  )
</script>

Setting up CssObject

The other integral part is a component that accepts DOM contents in the default slot and places them in the scene and renders them with the ThreeJS CSS2DRenderer:

CssObject.svelte
<script>
  import { T } from '@threlte/core'
  import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'

  export let pointerEvents = false

  let element
</script>

<div
  bind:this={element}
  style:pointer-events={pointerEvents ? 'auto' : 'none !important'}
  style:will-change="transform"
>
  <slot />
</div>

{#if element}
  <T
    {...$$restProps}
    is={CSS2DObject}
    args={[element]}
    let:ref
  />
{/if}

This component renders children into a div, and allows nested Threlte components via the three slot. It passes all other properties through, letting us use it like so:

<CssObject
  position={[-1, 2, 1]}
  center={[-0.2, 0.5]}
>
  <CounterLabel label="Hello" />

  <T.Mesh slot="three">
    <T.SphereGeometry args={[0.25]} />
    <T.MeshStandardMaterial color="#4F6FF6" />
  </T.Mesh>
</CssObject>

where <CounterLabel> is a normal Svelte component outside Threlte’s control, but the mesh is a component inside the scene hooked in with slot="three".