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'
let element: HTMLElement | undefined = $state()
</script>
<div
id="css-renderer-target"
bind:this={element}
/>
<div id="main">
<Canvas>
{#if element !== undefined}
<Scene {element} />
{/if}
</Canvas>
</div>
<style>
div#main {
height: 100%;
}
#css-renderer-target {
left: 0;
pointer-events: none;
position: absolute;
top: 0;
}
</style>
<script lang="ts">
import { T } from '@threlte/core'
let { label = '', ...props }: { label?: string } = $props()
let count = $state(0)
const text = $derived(`${label} - ${count}`)
</script>
<button
on:click={() => {
count += 1
}}
>
{text}
</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 lang="ts">
import type { Props } from '@threlte/core'
import type { Snippet } from 'svelte'
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'
import { T } from '@threlte/core'
type CssObjectProps = Props<typeof CSS2DObject> & {
content?: Snippet
pointerEvents?: boolean
}
let { content, pointerEvents = false, children, ...props }: CssObjectProps = $props()
let element: HTMLElement | undefined = $state()
</script>
<div
bind:this={element}
style:pointer-events={pointerEvents ? 'auto' : 'none !important'}
style:will-change="transform"
>
{@render content?.()}
</div>
{#if element !== undefined}
<T
{...props}
is={CSS2DObject}
args={[element]}
>
{#snippet children({ ref })}
{@render children?.({ ref })}
{/snippet}
</T>
{/if}
<script lang="ts">
import CounterLabel from './CounterLabel.svelte'
import CssObject from './CssObject.svelte'
import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js'
import { OrbitControls } from '@threlte/extras'
import { T, useTask, useThrelte } from '@threlte/core'
import type { ColorRepresentation, Vector3Tuple } from 'three'
let { element }: { element: HTMLElement } = $props()
const { autoRenderTask, camera, scene, size } = useThrelte()
// note that the renderer won't be reactive if `element` updates
// you'd have to do `$derived(new CSS2DRenderer({element}))` if you'd want that to be the case
const cssRenderer = new CSS2DRenderer({ element })
$effect(() => {
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
const last = scene.matrixWorldAutoUpdate
scene.matrixWorldAutoUpdate = false
$effect(() => {
return () => {
scene.matrixWorldAutoUpdate = last
}
})
// 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
}
)
type CssObjectParams = {
color: ColorRepresentation
label: string
position: Vector3Tuple
}
const params: CssObjectParams[] = [
{
color: '#4F6FF6',
label: 'Hello',
position: [-1, 2, 1]
},
{
color: '#6FF64F',
label: 'CSS',
position: [1, 2, 1]
},
{
color: '#F64F6F',
label: 'Renderer',
position: [1, 2, -1]
}
]
</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>
{#each params as { color, label, position }}
<CssObject
{position}
center={[0, 0.5]}
>
{#snippet content()}
<CounterLabel {label} />
{/snippet}
<T.Mesh>
<T.SphereGeometry args={[0.25]} />
<T.MeshStandardMaterial {color} />
</T.Mesh>
</CssObject>
{/each}
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
.
<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
:
<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]}
/>
{/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"
.