threlte logo
@threlte/extras

<Decal>

A declarative component for Three’s DecalGeometry.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import { Suspense } from '@threlte/extras'
  import { World } from '@threlte/rapier'
  import Scene from './Scene.svelte'
  import { Pane, Checkbox } from 'svelte-tweakpane-ui'

  let controls = $state(false)
  let debug = $state(false)
</script>

<Pane
  title="Decal"
  position="fixed"
>
  <Checkbox
    label="Controls"
    bind:value={controls}
  />
  <Checkbox
    label="Debug"
    bind:value={debug}
  />
</Pane>

<div>
  <Canvas>
    <World>
      <Suspense>
        <Scene
          {controls}
          {debug}
        />
      </Suspense>
    </World>
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { type Vector3Tuple, DoubleSide } from 'three'
  import { T } from '@threlte/core'
  import {
    Decal,
    TransformControls,
    useTexture,
    OrbitControls,
    VirtualEnvironment,
    useSuspense
  } from '@threlte/extras'
  import { AutoColliders, Collider, RigidBody } from '@threlte/rapier'

  let { controls = false, debug = false } = $props()

  const suspend = useSuspense()
  const svelteIcon = suspend(useTexture('/icons/svelte.png'))
  const threlteIcon = suspend(useTexture('/icons/mstile-150x150.png'))

  let bodies = $state([])
  let position = $state([0.5, 0, 0.5])

  let current = 0
  setInterval(() => {
    current += 1
    current %= bodies.length
    const body = bodies[current]

    body.setLinvel({ x: 0, y: 0, z: 0 })
    body.setAngvel({ x: 0, y: 0, z: 0, w: 1 })
    body.setTranslation(
      { x: (Math.random() - 0.5) * 0.1, y: 5, z: (Math.random() - 0.5) * 0.1 },
      true
    )
  }, 400)
</script>

<T.PerspectiveCamera
  makeDefault
  position={[5, 1, 4]}
  oncreate={(ref) => ref.lookAt(0, 1, 0)}
>
  <OrbitControls
    enablePan={false}
    enableZoom={false}
    enableDamping
    target={[0, 1, 0]}
  />
</T.PerspectiveCamera>

<T.DirectionalLight
  castShadow
  position={[5, 5, 5]}
  intensity={1.25}
/>

<!-- <T.BoxGeometry args={[3, 0.2, 3]} /> -->

<T.Mesh receiveShadow>
  <Collider
    shape={'ball'}
    args={[1]}
  />
  <T.SphereGeometry args={[1, 256, 128]} />
  <T.MeshStandardMaterial roughness={0.1} />

  {#if $svelteIcon}
    <Decal
      {position}
      {debug}
    >
      {#snippet children({ ref })}
        <T.MeshStandardMaterial
          map={$svelteIcon}
          transparent
          roughness={0.2}
          polygonOffset
          polygonOffsetFactor={-10}
        />
        {#if controls}
          <TransformControls
            oncreate={(ref) => {
              ref.position.fromArray(position)
            }}
            onchange={(event) => {
              if (event.target.object) event.target.object.position.toArray(position)
            }}
          />
        {/if}
      {/snippet}
    </Decal>
  {/if}
</T.Mesh>

{#each { length: 20 } as _, index (index)}
  <RigidBody
    bind:rigidBody={bodies[index]}
    oncreate={(ref) => {
      ref.setTranslation({ x: 0, y: -10 + index, z: 0 })
    }}
  >
    <T.Mesh castShadow>
      <Collider
        shape="ball"
        args={[0.3]}
        restitution={0.2}
      />
      <T.SphereGeometry args={[0.3, 256, 128]} />
      <T.MeshStandardMaterial roughness={0.2} />

      <Decal
        position={[0.35, 0.35, 0.35]}
        rotation={Math.PI / 4}
        scale={1}
        depthTest
        {debug}
      >
        <T.MeshStandardMaterial
          map={$threlteIcon}
          transparent
          roughness={0.2}
          polygonOffset
          polygonOffsetFactor={-10}
        />
      </Decal>
    </T.Mesh>
  </RigidBody>
{/each}

{#snippet lightformer(
  color: string,
  shape: 'circle' | 'plane',
  size: number,
  position: [number, number, number]
)}
  <T.Group {position}>
    <T.Mesh oncreate={(ref) => ref.lookAt(0, 0, 0)}>
      {#if shape === 'circle'}
        <T.CircleGeometry args={[size / 2]} />
      {:else}
        <T.PlaneGeometry args={[size, size]} />
      {/if}
      <T.MeshBasicMaterial
        {color}
        side={DoubleSide}
      />
    </T.Mesh>
  </T.Group>
{/snippet}

<VirtualEnvironment>
  {@render lightformer('#FF4F4F', 'plane', 20, [0, 0, -20])}
  {@render lightformer('#FFD0CB', 'circle', 5, [0, 5, 0])}
  {@render lightformer('#2223FF', 'plane', 8, [-3, 0, 4])}
</VirtualEnvironment>

By default, the decal will use its parent mesh as the decal surface.

The decal projector must intersect the surface to be visible.

If you do not specifiy a rotation the decal projector will look at the parents center point. You can also pass a single number as the rotation, which will spin the decal along its surface.

Examples

Basic Example

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

<T.Mesh>
  <T.SphereGeometry />
  <T.MeshStandardMaterial />
  <Decal
    position={[0, 0, 0]}
    rotation={[0, 0, 0]}
    scale={1}
  >
    <!-- Will override the default material if added -->
    <T.MeshBasicMaterial
      map={texture}
      polygonOffset
      polygonOffsetFactor={-1}
    />
  </Decal>
</mesh>

Component Signature

<Decal> extends <T . Mesh> and supports all its props, slot props, bindings and events.

Props

name
type
required
default
description

depthTest
boolean
no
true

mesh
Mesh
no
An optional parent mesh to project the decal

polygonOffsetFactor
number
no
-10

position
Vector3Tuple
no
Positions the center of the decal projector

rotation
EulerTuple
no
Euler for manual orientation or a single float for closest-vertex-normal orient

scale
Vector3Tuple
no

src
string | Texture
no
A url or texture prop can be used instead of a material override