threlte logo

ScreenQuad

This example demonstrates a well-known technique for doing simple postprocessing utilizing a “screen-quad”. Mouse around the canvas to see the effect.

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

  let radius = $state(0.05)
</script>

<Pane
  position="fixed"
  title="simple post-processing"
>
  <Slider
    label="radius"
    bind:value={radius}
    min={0.01}
    max={0.1}
    step={0.01}
  />
</Pane>

<Canvas autoRender={false}>
  <Scene {radius} />
</Canvas>
<script lang="ts">
  import ScreenQuadGeometry, { vertexShader } from './ScreenQuadGeometry.svelte'
  import { Scene, Uniform, Vector2 } from 'three'
  import { T, useTask, useThrelte } from '@threlte/core'
  import { Environment, interactivity, OrbitControls, useFBO, useGltf } from '@threlte/extras'

  let { radius = 1 }: { radius?: number } = $props()

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

  const fbo = useFBO()
  const _scene = new Scene()

  useTask(
    () => {
      const last = renderer.getRenderTarget()
      renderer.setRenderTarget(fbo)
      renderer.render(scene, camera.current)
      renderer.setRenderTarget(last)
      renderer.render(_scene, camera.current)
    },
    { stage: renderStage }
  )

  /**
   * put your interesting effects in this shader.
   */
  const fragmentShader = `
		precision highp float;
		uniform sampler2D uScene;
		uniform vec2 uResolution;
		uniform vec2 uMouse;
		uniform float uRadius;

		void main() {
			vec2 uv = gl_FragCoord.xy / uResolution.xy;
			vec4 color = texture2D(uScene, uv);

			vec2 d = (uv - uMouse);
			d.x *= uResolution.x / uResolution.y;

			float circle = 1.0 - smoothstep(
				uRadius,
				uRadius,
				dot(d, d)
			);

			color.rgb *= circle;
			gl_FragColor = color;
		}
	`

  const uScene = new Uniform(fbo.texture)
  const uResolution = new Uniform(new Vector2())

  $effect(() => {
    uResolution.value.set($size.width, $size.height)
  })

  const gltf = useGltf('/models/spaceships/Bob.gltf')

  const uMouse = new Uniform(new Vector2(0.5, 0.5))

  const { pointer } = interactivity()

  $effect(() => {
    uMouse.value.copy($pointer).addScalar(1).divideScalar(2)
  })
</script>

<T.PerspectiveCamera
  makeDefault
  position={5}
>
  <OrbitControls />
</T.PerspectiveCamera>

<!-- this geometry is so simple that frustrum culling it would actually be more work -->
<T.Mesh
  frustrumCulled={false}
  attach={_scene}
>
  <T.RawShaderMaterial
    {vertexShader}
    {fragmentShader}
    uniforms={{
      uRadius: {
        value: 1
      }
    }}
    uniforms.uScene={uScene}
    uniforms.uResolution={uResolution}
    uniforms.uMouse={uMouse}
    uniforms.uRadius.value={radius}
  />
  <ScreenQuadGeometry />
</T.Mesh>

{#await gltf then { scene }}
  <T is={scene} />
{/await}

<Environment
  url="/textures/equirectangular/hdr/shanghai_riverside_1k.hdr"
  isBackground
/>
<script
  lang="ts"
  module
>
  // (-1, 3)
  //    |\
  //    | \
  //    |  \
  //    |   \
  //    |    \
  //    |     \
  //    |      \
  //    |       \
  //    |________\
  // (-1, -1)   (3, -1)
  const vertices = new Float32Array([-1, -1, 3, -1, -1, 3])

  export const vertexShader = `
		precision highp float;
		attribute vec2 position;

		void main() {
			gl_Position = vec4(position, 1.0, 1.0);
		}
	`

  const center = new Vector3()
</script>

<script lang="ts">
  import type { Props } from '@threlte/core'
  import { BufferAttribute, BufferGeometry, Sphere, Vector3 } from 'three'
  import { T } from '@threlte/core'

  const geometry = new BufferGeometry()
  geometry.setAttribute('position', new BufferAttribute(vertices, 2))
  geometry.boundingSphere = new Sphere().set(center, Infinity)

  let { children }: Props<typeof BufferGeometry> = $props()
</script>

<T is={geometry}>
  {@render children?.({ ref: geometry })}
</T>

Overview

The basic idea of postprocessing is to draw the scene to a frame-buffer that can then be sent into another shader. The output of this shader is used as the texture or material for a mesh that covers the screen. To do this we need two things:

  1. a webgl render target
  2. a separate scene from the one that threlte provides

The useFBO hook returns a render target. This will be what we render the main scene to every frame and pass into the fragment shader as a texture.

A separate scene is needed so that we can add the screen-quad mesh to it and render it. The idea is that the only thing in this scene will be the screen-quad mesh that has the post-processed scene as its material. This mesh can’t be added to the main scene because it would cause a circular dependency.

So the steps are as follows:

  1. render the “main” scene to the render target.
  2. render the separate scene.

There are some sub-steps inbetween but these are the two important bits that happen in the useTask callback in the <Scene> component.

ScreenQuadGeometry

This component creates a right triangle such that its right angle is positioned in the lower-left of the canvas. The size of the triangle is such that the hypotenuse only touches the top-right corner of the canvas. The triangle is in clip-space so we need a special vertex shader to use it.

The vertex shader string is exported from the <ScreenQuadGeometry> component to be used with either a Three.RawShaderMaterial or Three.ShaderMaterial.

Scene

There are a few notable techniques in the <Scene> component.

Firstly, The screen-quad mesh is not frustrum culled. This is because the geometry is so simple that culling it would actually requare more work. If you culled its geometry, you’d actually be creating more vertices.

This <T.Mesh> uses threlte’s attach prop to add it to the secondary scene instead of the default scene created by threlte.

Secondly, the render task that threlte automatically uses is disabled and a custom render task is used instead. This is explained on the useTask page.

Lastly, the uvs are used in the fragment shader to use as an index into the scene texture. For this reason, the resolution of the canvas is sent into the shader as a uniform. An effect is ran to keep the uniform in sync with the current size of the canvas.