@threlte/extras
<ImageMaterial>
Adapted from drei’s <Image>
component, with additional color processing extras.
A shader-based image material component with auto-cover (similar to css/background: cover).
<script lang="ts">
import { Canvas, watch } from '@threlte/core'
import Scene from './Scene.svelte'
import { Pane, Slider, Checkbox, Folder, Color } from 'svelte-tweakpane-ui'
import {
brightness,
contrast,
negative,
hue,
saturation,
lightness,
monochromeColor,
monochromeStrength,
textureOverrideEnabled,
alphaThreshold,
alphaSmoothing
} from './props'
watch(textureOverrideEnabled, (enabled) => {
if (enabled) {
hue.set(0.2)
saturation.set(-1)
lightness.set(0.15)
} else {
hue.set(0)
saturation.set(0)
lightness.set(0)
}
})
</script>
<div>
<Canvas>
<Scene />
</Canvas>
</div>
<Pane
title="Image"
position="fixed"
>
<Folder title="Color processing">
<Slider
bind:value={$brightness}
label="brightness"
min={-1}
max={1}
/>
<Slider
bind:value={$contrast}
label="contrast"
min={-1}
max={1}
/>
<Slider
bind:value={$hue}
label="hue"
min={0}
max={1}
/>
<Slider
bind:value={$saturation}
label="saturation"
min={-1}
max={1}
/>
<Slider
bind:value={$lightness}
label="lightness"
min={-1}
max={1}
/>
<Slider
bind:value={$monochromeStrength}
label="monochromeStrength"
min={0}
max={1}
/>
<Color
bind:value={$monochromeColor}
label="monochromeColor"
/>
<Checkbox
bind:value={$negative}
label="negative"
/>
</Folder>
<Folder title="Color processing with a texture">
<Checkbox
bind:value={$textureOverrideEnabled}
label="enabled"
/>
<Slider
bind:value={$alphaThreshold}
label="alphaThreshold"
min={0}
max={1}
/>
<Slider
bind:value={$alphaSmoothing}
label="alphaSmoothing"
min={0}
max={1}
/>
</Folder>
</Pane>
<style>
div {
height: 100%;
}
</style>
<script lang="ts">
import { Vector2, PlaneGeometry } from 'three'
import { T } from '@threlte/core'
import type { BufferAttribute } from 'three'
export let args: any[]
class BentPlaneGeometry extends PlaneGeometry {
constructor(radius: number, ...args: ConstructorParameters<typeof PlaneGeometry>) {
super(...args)
const p = this.parameters
const hw = p.width * 0.5
const a = new Vector2(-hw, 0)
const b = new Vector2(0, radius)
const c = new Vector2(hw, 0)
const ab = new Vector2().subVectors(a, b)
const bc = new Vector2().subVectors(b, c)
const ac = new Vector2().subVectors(a, c)
const r = (ab.length() * bc.length() * ac.length()) / (2 * Math.abs(ab.cross(ac)))
const center = new Vector2(0, radius - r)
const baseV = new Vector2().subVectors(a, center)
const baseAngle = baseV.angle() - Math.PI * 0.5
const arc = baseAngle * 2
const uv = this.attributes.uv as BufferAttribute
const pos = this.attributes.position as BufferAttribute
const mainV = new Vector2()
for (let i = 0; i < uv.count; i += 1) {
const uvRatio = 1 - uv.getX(i)
const y = pos.getY(i)
mainV.copy(c).rotateAround(center, arc * uvRatio)
pos.setXYZ(i, mainV.x, y, -mainV.y)
}
pos.needsUpdate = true
}
}
</script>
<T
is={BentPlaneGeometry}
{args}
>
<slot />
</T>
<script lang="ts">
import { T } from '@threlte/core'
import { ImageMaterial, type IntersectionEvent } from '@threlte/extras'
import { spring } from 'svelte/motion'
import BentPlaneGeometry from './BentPlaneGeometry.svelte'
import { DoubleSide } from 'three'
import {
brightness,
contrast,
negative,
hue,
saturation,
lightness,
monochromeColor,
monochromeStrength,
colorProcessingTexture,
alphaThreshold,
alphaSmoothing
} from './props'
export let url: string
let hovered = false
const scale = spring(1)
const radius = spring(0.1)
const zoom = spring(1)
$: scale.set(hovered ? 1.3 : 1)
$: radius.set(hovered ? 0.25 : 0.1)
$: zoom.set(hovered ? 1.25 : 1)
const stopPropagation =
(fn: () => void) => (event: IntersectionEvent<'pointerover' | 'pointerleave'>) => {
event.stopPropagation()
fn()
}
</script>
<T.Mesh
scale={$scale}
{...$$restProps}
onpointerover={stopPropagation(() => (hovered = true))}
onpointerleave={stopPropagation(() => (hovered = false))}
>
<BentPlaneGeometry args={[0.1, 1, 1, 20, 20]} />
<ImageMaterial
transparent
side={DoubleSide}
{url}
radius={$radius}
zoom={$zoom}
alphaThreshold={$alphaThreshold}
alphaSmoothing={$alphaSmoothing}
brightness={$brightness}
contrast={$contrast}
negative={$negative}
hue={$hue}
saturation={$saturation}
lightness={$lightness}
monochromeColor={$monochromeColor}
monochromeStrength={$monochromeStrength}
colorProcessingTexture={$colorProcessingTexture}
/>
</T.Mesh>
<script lang="ts">
import { T } from '@threlte/core'
import { OrbitControls, Suspense, interactivity } from '@threlte/extras'
import Card from './Card.svelte'
import { textureOverrideEnabled } from './props'
import RgbaTexture from './rgbaProcessingTexture/RgbaTexture.svelte'
interactivity()
let urls = [
'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Caravaggio_-_Boy_Bitten_by_a_Lizard.jpg/762px-Caravaggio_-_Boy_Bitten_by_a_Lizard.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/The_Large_Plane_Trees_%28Road_Menders_at_Saint-R%C3%A9my%29%2C_by_Vincent_van_Gogh%2C_Cleveland_Museum_of_Art%2C_1947.209.jpg/963px-The_Large_Plane_Trees_%28Road_Menders_at_Saint-R%C3%A9my%29%2C_by_Vincent_van_Gogh%2C_Cleveland_Museum_of_Art%2C_1947.209.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c9/KlimtDieJungfrau.jpg/803px-KlimtDieJungfrau.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/The_Denial_of_St._Peter_-_Gerard_Seghers_-_Google_Cultural_Institute.jpg/1024px-The_Denial_of_St._Peter_-_Gerard_Seghers_-_Google_Cultural_Institute.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Antoine_Vollon_-_Mound_of_Butter_-_National_Gallery_of_Art.jpg/935px-Antoine_Vollon_-_Mound_of_Butter_-_National_Gallery_of_Art.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/De_bedreigde_zwaan_Rijksmuseum_SK-A-4.jpeg/911px-De_bedreigde_zwaan_Rijksmuseum_SK-A-4.jpeg'
]
const count = urls.length
const radius = 1.4
</script>
<T.PerspectiveCamera
makeDefault
fov={20}
position={[2, 2, 10]}
>
<OrbitControls
autoRotate
enableDamping
enableZoom={false}
/>
</T.PerspectiveCamera>
{#if $textureOverrideEnabled}
<RgbaTexture />
{/if}
<Suspense>
{#each urls as url, index (url)}
<Card
{url}
position={[
Math.sin((index / count) * Math.PI * 2) * radius,
0,
Math.cos((index / count) * Math.PI * 2) * radius
]}
rotation={[0, Math.PI + (index / count) * Math.PI * 2, 0]}
/>
{/each}
</Suspense>
import { writable } from 'svelte/store'
import type { Texture } from 'three'
export const alphaThreshold = writable(0.5)
export const alphaSmoothing = writable(0.15)
export const brightness = writable(0)
export const contrast = writable(0)
export const hue = writable(0)
export const saturation = writable(0)
export const lightness = writable(0)
export const negative = writable(false)
export const monochromeColor = writable('#ed8922')
export const monochromeStrength = writable(0)
export const textureOverrideEnabled = writable(false)
export const colorProcessingTexture = writable<Texture | undefined>()
<script lang="ts">
import { useTask, useThrelte, T } from '@threlte/core'
import { useTexture, Portal, HTML } from '@threlte/extras'
const { renderer, autoRenderTask, camera } = useThrelte()
import { colorProcessingTexture } from '../props'
import { onDestroy, onMount } from 'svelte'
import {
DoubleSide,
Mesh,
OrthographicCamera,
PlaneGeometry,
Scene,
ShaderMaterial,
WebGLMultipleRenderTargets
} from 'three'
import fragmentShader from './fragmentShader.glsl?raw'
import vertexShader from './vertexShader.glsl?raw'
// Multiple render targets to visualize RGBA channels.
const rgbaTextureTarget = new WebGLMultipleRenderTargets(256, 256, 5)
const scene = new Scene()
const orthoCamera = new OrthographicCamera(-1, 1, 1, -1, -1, 1)
const shaderMaterial = new ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uAlphaTexture: { value: null }
},
vertexShader,
fragmentShader
})
const alphaTexture = useTexture('/textures/alpha.jpg')
$: shaderMaterial.uniforms.uAlphaTexture.value = $alphaTexture
const quad = new Mesh(new PlaneGeometry(2, 2), shaderMaterial)
scene.add(quad)
useTask(
(delta) => {
shaderMaterial.uniforms.uTime.value += delta
renderer.setRenderTarget(rgbaTextureTarget)
renderer.render(scene, orthoCamera)
renderer.setRenderTarget(null)
},
{
before: autoRenderTask
}
)
onMount(() => {
colorProcessingTexture.set(rgbaTextureTarget.texture[0])
})
onDestroy(() => {
colorProcessingTexture.set(undefined)
})
</script>
<!-- VISUALIZATION OF EACH RGBA CHANNEL -->
<Portal object={$camera}>
<T.Group
position.x={-0.1}
position.z={-0.5}
position.y={0.06}
scale={0.03}
>
<T.Mesh>
<T.MeshBasicMaterial
map={rgbaTextureTarget.texture[1]}
side={DoubleSide}
/>
<T.PlaneGeometry />
<HTML center>
<span style="color:white;">Hue(R)</span>
</HTML>
</T.Mesh>
<T.Mesh position.x={1}>
<T.MeshBasicMaterial
map={rgbaTextureTarget.texture[2]}
side={DoubleSide}
/>
<T.PlaneGeometry />
<HTML center>
<span style="color:white;">Saturation(G)</span>
</HTML>
</T.Mesh>
<T.Mesh position.x={2}>
<T.MeshBasicMaterial
map={rgbaTextureTarget.texture[3]}
side={DoubleSide}
/>
<T.PlaneGeometry />
<HTML center>
<span style="color:white;">Lightness(B)</span>
</HTML>
</T.Mesh>
<T.Mesh position.x={3}>
<T.MeshBasicMaterial
map={rgbaTextureTarget.texture[4]}
side={DoubleSide}
/>
<T.PlaneGeometry />
<HTML center>
<span style="color:white;">Alpha(A)</span>
</HTML>
</T.Mesh>
</T.Group>
</Portal>
varying vec2 vUv;
uniform sampler2D uAlphaTexture;
uniform float uTime;
layout (location = 1) out vec4 gR;
layout (location = 2) out vec4 gG;
layout (location = 3) out vec4 gB;
layout (location = 4) out vec4 gA;
float rand(vec2 n) {
return fract(sin(dot(n, vec2(12.9898f, 4.1414f))) * 43758.5453f);
}
// https://www.shadertoy.com/view/tljXR1
float noise(vec2 p) {
vec2 ip = floor(p);
vec2 u = fract(p);
u = u * u * (3.0f - 2.0f * u);
float res = mix(mix(rand(ip), rand(ip + vec2(1.0f, 0.0f)), u.x), mix(rand(ip + vec2(0.0f, 1.0f)), rand(ip + vec2(1.0f, 1.0f)), u.x), u.y);
return res * res;
}
#define NUM_OCTAVES 5
float fbm(vec2 x) {
float v = 0.0f;
float a = 0.5f;
vec2 shift = vec2(100);
// Rotate to reduce axial bias
mat2 rot = mat2(cos(0.5f), sin(0.5f), -sin(0.5f), cos(0.50f));
for (int i = 0; i < NUM_OCTAVES; ++i) {
v += a * noise(x);
x = rot * x * 2.0f + shift;
a *= 0.5f;
}
return v;
}
float hexGrid(float scale) {
vec2 u = scale * vUv;
vec2 s = vec2(1.f, 1.732f);
vec2 a = mod(u, s) * 2.f - s;
vec2 b = mod(u + s * .5f, s) * 2.f - s;
return pow(0.5f * min(dot(a, a), dot(b, b)), 3.f) * 2.f;
}
void main() {
vec2 p = vUv * 0.5f - 1.f;
float t = uTime * 0.15f;
float rad = atan(p.x, p.y) + t * 0.2f;
float hue = fbm(35.f * vec2(cos(rad), sin(rad)) + 30.f * vec2(fbm(p + t), -fbm(p + t)));
hue = pow(hue, 2.f);
float saturation = clamp(pow(distance(0.5f, fract((vUv.x + vUv.y) + uTime * 0.2f)), 2.f) * 10.f, 0.f, 1.f);
float lightness = clamp(hexGrid(8.f) * pow(distance(0.5f, fract(vUv.x * 16.f + uTime)), 2.f) * 20.f, 0.f, 1.f);
float alpha = texture2D(uAlphaTexture, vUv).r;
pc_fragColor = vec4(hue, saturation, lightness, alpha);
gR = vec4(hue, 0.f, 0.f, 1.f);
gG = vec4(0.f, saturation, 0.f, 1.f);
gB = vec4(0.f, 0.f, lightness, 1.f);
gA = vec4(alpha, alpha, alpha, 1.f);
}
varying vec2 vUv;
void main() {
gl_Position = vec4(position, 1.0f);
vUv = uv;
}
Images from Wikipedia. Carousel originally by Cyd Stumpel.
Example
<script lang="ts">
import { DoubleSide } from 'three'
import { ImageMaterial } from '@threlte/extras'
</script>
<T.Mesh>
<T.PlaneGeometry />
<ImageMaterial
transparent
side={DoubleSide}
url="KlimtDieJungfrau.jpg"
radius={0.1}
zoom={1.1}
/>
<T.Mesh>
<ImageMaterial>
can also be used with instanced or batched meshes.
Color processing effects
The <ImageMaterial />
component offers a range of properties for dynamic color processing.
The properties brightness
, contrast
, hue
, saturation
, and lightness
adjust the image’s initial values additively.
To decrease brightness, for instance, you would use a negative value, while a positive value would increase it. The hue
shift is the only exception,
its values range from 0 to 1, representing a complete cycle around the color wheel. Notably, values 0 and 1 are equivalent, indicating the same hue position.
For the monochrome effect, specify your preferred tint using the monochromeColor
property,
and control the effect’s intensity with monochromeStrength
.
Setting this strength to 0 disables the effect entirely, while a value of 1 applies it fully,
rendering the image in varying shades of a single color.
Advanced color processing with a texture
The colorProcessingTexture
property enables advanced color processing by allowing you to specify a texture
that changes the strength and pattern of color effects. It can be used to create dynamic, animated effects as well as static ones that target only
specified image areas.
Each texture channel controls a different color processing effect:
- Red for hue
- Green for saturation,
- Blue for lightness
- Alpha for transparency.
Red, green and blue channels are applied multiplicatively to the values of hue, saturation and lightness.
The alpha channel acts differently, providing a flexible alpha override mechanism, that uses a range of values for
dynamic image reveal effect. With changing the alphaThreshold
property, areas with alpha values approaching 1 are revealed first,
followed by regions with values tending towards 0.
To further control this effect, the alphaSmoothing
property allows for a gradual fade-in effect within a specified range.
For instance, with an alphaThreshold of 0.5 and an alphaSmoothing set to 0.15, alpha values spanning from 0.5 to 0.65
will smoothly transition into visibility.
Enable “color processing with a texture” in the example on top of this page to see the effect applying RGBA color processing texture can have.
Alpha image used in the example. The lighter values towards the center are revealed first.
Order of processing effects
- Alpha override
- Brightness
- Contrast
- Hue, Saturation, Lightness
- Monochrome
- Negative
Component Signature
<ImageMaterial>
extends
<T
.
ShaderMaterial>
and supports all its props, slot props, bindings and events.