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 type { Vector3Tuple } from 'three'
  import type { Wall } from './types'
  import { Canvas } from '@threlte/core'
  import { Checkbox, Pane } from 'svelte-tweakpane-ui'
  import { Mesh, Shape } from 'three'

  const mesh = new Mesh()

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

  const walls: Wall[] = [
    {
      height: 3,
      shape: new Shape()
        .moveTo(3.5, -4.5)
        .lineTo(3.5, -3.5)
        .lineTo(5.5, -3.5)
        .lineTo(5.5, -0.5)
        .lineTo(-2.5, -0.5)
        .lineTo(-2.5, 0.5)
        .lineTo(5.5, 0.5)
        .lineTo(5.5, 3.5)
        .lineTo(-0.5, 3.5)
        .lineTo(-0.5, 4.5)
        .lineTo(6.5, 4.5)
        .lineTo(6.5, -4.5)
    },
    {
      height: 3,
      shape: new Shape()
        .moveTo(-6.5, -4.5)
        .lineTo(-6.5, 4.5)
        .lineTo(-3.5, 4.5)
        .lineTo(-3.5, 3.5)
        .lineTo(-5.5, 3.5)
        .lineTo(-5.5, -3.5)
        .lineTo(0.5, -3.5)
        .lineTo(0.5, -4.5)
    }
  ]

  // where is the mesh going?
  const positions: Vector3Tuple[] = [
    [2, -2, 0],
    [-4, -2, 0],
    [-4, 2, 0],
    [-2, 2, 0],
    [-2, 6, 0],
    [-8, 6, 0],
    [-8, -6, 0],
    [2, -6, 0]
  ]
</script>

<Pane
  position="fixed"
  title="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 = new RenderPass(scene)
  composer.addPass(renderPass)

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

  export const outlineEffectOptions: ConstructorParameters<typeof OutlineEffect>[2] = {
    blendFunction: BlendFunction.ALPHA,
    edgeStrength: 100,
    pulseSpeed: 0.0,
    xRay: true,
    blur: true
  }

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

  const outlineEffectPass = new EffectPass(undefined, outlineEffect)
  composer.addPass(outlineEffectPass)

  $effect(() => {
    renderPass.mainCamera = $camera
    outlineEffect.mainCamera = $camera
    outlineEffectPass.mainCamera = $camera
  })

  $effect(() => {
    return () => {
      composer.removeAllPasses()
      outlineEffectPass.dispose()
      renderPass.dispose()
      composer.dispose()
    }
  })

  $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 { ExtrudeGeometryOptions, 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 = 0
  const positionTween = new Tween(positions[positionIndex], {
    duration: 400,
    easing: quadInOut
  })
  let time = 0

  // if `positions` changes, restart
  $effect(() => {
    positions
    positionIndex = 0
    positionTween.set(positions[positionIndex], { duration: 0 })
    time = 0
  })

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

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

  const extrudeOptions: ExtrudeGeometryOptions = { bevelEnabled: false }
</script>

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

<T.OrthographicCamera
  makeDefault
  position={[10, 10, 10]}
  zoom={50}
>
  <OrbitControls
    {autoRotate}
    enableDamping
  />
</T.OrthographicCamera>

<T.Group rotation.x={-1 * 0.5 * Math.PI}>
  {#each walls as { height, shape }}
    <T.Mesh scale.z={height}>
      <T.ExtrudeGeometry args={[shape, extrudeOptions]} />
      <T.MeshStandardMaterial color="silver" />
    </T.Mesh>
  {/each}

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

  <T.Mesh
    scale={100}
    position.z={-1.01}
  >
    <T.PlaneGeometry />
    <T.MeshStandardMaterial
      color="green"
      side={DoubleSide}
    />
  </T.Mesh>
</T.Group>
import type { Shape } from 'three'

export type Wall = {
  shape: Shape
  height: number
}

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.