threlte logo

Outlines

Implements the Outline postprocessing pass. Vanilla threejs example here

An outlined cube loops through a maze, with a different outline color when the object is hidden.

<script lang="ts">
  import CustomRenderer from './CustomRenderer.svelte'
  import Scene from './Scene.svelte'
  import { Canvas } from '@threlte/core'
  import { Mesh } from 'three'
  import { Checkbox, Pane } from 'svelte-tweakpane-ui'
  import type { Wall } from './types'

  const mesh = new Mesh()

  let paused = $state(false)
  let autoRotate = $state(true)

  const walls: Wall[] = [
    {
      position: [6, 2, 4],
      rotation: [0, 0.5 * Math.PI, 0],
      size: [7, 4, 1]
    },
    {
      position: [-6, 2, 4],
      rotation: [0, 0.5 * Math.PI, 0],
      size: [7, 4, 1]
    },
    {
      position: [-4, 2, 0],
      rotation: [0, 0, 0],
      size: [5, 4, 1]
    },
    {
      position: [4, 2, 0],
      rotation: [0, 0, 0],
      size: [5, 4, 1]
    },
    {
      position: [-3, 2, 7],
      rotation: [0, 0, 0],
      size: [7, 4, 1]
    },
    {
      position: [5, 2, 7],
      rotation: [0, 0, 0],
      size: [3, 4, 1]
    },
    {
      position: [-1, 2, 3.5],
      rotation: [0, 0, 0],
      size: [10, 4, 1]
    }
  ]

  // where is the mesh going?
  const positions: Vector3Tuple[] = [
    [0, 1, -3],
    [0, 1, 1.5],
    [4.7, 1, 1.5],
    [4.7, 1, 5],
    [2, 1, 5],
    [2, 1, 9],
    [8, 1, 9],
    [8, 1, -3]
  ]
</script>

<Pane
  position="fixed"
  name="outline effect"
>
  <Checkbox
    bind:value={paused}
    label="paused"
  />
  <Checkbox
    bind:value={autoRotate}
    label="auto rotate camera"
  />
</Pane>

<Canvas>
  <Scene
    play={!paused}
    {autoRotate}
    {mesh}
    {walls}
    {positions}
  />
  <CustomRenderer {mesh} />
</Canvas>
<script lang="ts">
  import type { Mesh } from 'three'
  import { useTask, useThrelte } from '@threlte/core'
  import {
    BlendFunction,
    EffectComposer,
    EffectPass,
    OutlineEffect,
    RenderPass
  } from 'postprocessing'

  type Props = {
    mesh: Mesh
  }

  let { mesh }: Props = $props()

  const { scene, renderer, camera, size, autoRender, renderStage } = useThrelte()

  const composer = new EffectComposer(renderer)

  const renderPass = $derived(new RenderPass(scene, $camera))
  const outlineEffectOptions: ConstructorParameters<typeof OutlineEffect>[2] = {
    blendFunction: BlendFunction.ALPHA,
    edgeStrength: 100,
    pulseSpeed: 0.0,
    visibleEdgeColor: 0xffffff,
    hiddenEdgeColor: 0x9900ff,
    xRay: true,
    blur: true
  }

  const outlineEffect = $derived.by(() => {
    const effect = new OutlineEffect(scene, $camera, outlineEffectOptions)
    effect.selection.add(mesh)
    return effect
  })

  const outlineEffectPass = $derived(new EffectPass($camera, outlineEffect))

  $effect(() => {
    composer.addPass(renderPass)
    composer.addPass(outlineEffectPass)
    return () => {
      composer.removeAllPasses() // always remove all passes anytime the effect needs to rerun
    }
  })

  $effect(() => {
    composer.setSize($size.width, $size.height)
  })

  $effect(() => {
    const last = autoRender.current
    autoRender.set(false)
    return () => {
      autoRender.set(last)
    }
  })

  useTask(
    (delta) => {
      composer.render(delta)
    },
    { stage: renderStage, autoInvalidate: false }
  )
</script>
<script lang="ts">
  import type { Mesh, Vector3Tuple } from 'three'
  import type { Wall } from './types'
  import { DoubleSide } from 'three'
  import { Environment, OrbitControls } from '@threlte/extras'
  import { T, useTask } from '@threlte/core'
  import { Tween } from 'svelte/motion'
  import { quadInOut } from 'svelte/easing'

  type Props = {
    autoRotate?: boolean
    mesh: Mesh
    play?: boolean
    positions?: Vector3Tuple[]
    walls?: Wall[]
  }

  let { autoRotate = true, mesh, positions = [], play = true, walls = [] }: Props = $props()

  let positionIndex = $state(0)
  const position = $derived(positions[positionIndex])

  const positionTween = Tween.of(() => position, {
    duration: 400,
    easing: quadInOut
  })

  let time = $state(0)

  const { start, stop } = useTask((delta) => {
    time += delta
    if (time > 0.5) {
      positionIndex = (positionIndex + 1) % positions.length
      time = 0
    }
  })

  $effect(() => {
    if (play) {
      start()
      return () => {
        stop()
      }
    }
  })
</script>

{#each walls as { position, rotation, size }}
  <T.Mesh
    {position}
    {rotation}
  >
    <T.BoxGeometry args={size} />
    <T.MeshStandardMaterial color="silver" />
  </T.Mesh>
{/each}

<Environment url="/textures/equirectangular/hdr/shanghai_riverside_1k.hdr" />

<T.Group position={positionTween.current ?? [0, 0, 0]}>
  <T
    is={mesh}
    castShadow
  >
    <T.MeshStandardMaterial color="gold" />
    <T.BoxGeometry />
  </T>
</T.Group>

<T.PerspectiveCamera
  makeDefault
  position={[0, 6, -10]}
  fov={60}
>
  <OrbitControls
    {autoRotate}
    enableDamping
    target={[0, 0, 5]}
  />
</T.PerspectiveCamera>

<T.Mesh
  scale={100}
  rotation.x={-1 * 0.5 * Math.PI}
  receiveShadow
>
  <T.PlaneGeometry />
  <T.MeshStandardMaterial
    color="green"
    side={DoubleSide}
  />
</T.Mesh>
import type { Vector3Tuple } from 'three'
export type Wall = { position: Vector3Tuple; rotation: Vector3Tuple; size: Vector3Tuple }

How it Works

A mesh is created in App.svelte and passed into both Scene.svelte and CustomRenderer.svelte.

Scene

The scene is responsible for setting up the walls, floor, and attaching a geometry and material to the mesh while the custom renderer adds the mesh to the outline effects selection set.

CustomRenderer

Both passes that are added to the composer rely on the camera from the threlte context so they can are derived anytime the camera changes. When either of the passes updates, the composer’s passes are reset and the updated passes are added to the composer.