diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index ffe24e8db..39094c288 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -78,6 +78,7 @@ export default defineConfig({
{ text: 'Edges', link: '/guide/abstractions/edges' },
{ text: 'PositionalAudio', link: '/guide/abstractions/positional-audio' },
{ text: 'AnimatedSprite', link: '/guide/abstractions/animated-sprite' },
+ { text: 'Image', link: '/guide/abstractions/image' },
{ text: 'Billboard', link: '/guide/abstractions/billboard' },
],
},
diff --git a/docs/.vitepress/theme/components/ImageDemo.vue b/docs/.vitepress/theme/components/ImageDemo.vue
new file mode 100644
index 000000000..414624d11
--- /dev/null
+++ b/docs/.vitepress/theme/components/ImageDemo.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/component-list/components.ts b/docs/component-list/components.ts
index 3c9621373..22c720ccc 100644
--- a/docs/component-list/components.ts
+++ b/docs/component-list/components.ts
@@ -17,6 +17,7 @@ export default [
},
{ text: 'Sampler', link: '/guide/abstractions/sampler' },
{ text: 'PositionalAudio', link: '/guide/abstractions/positional-audio' },
+ { text: 'Image', link: '/guide/abstractions/image' },
{ text: 'Billboard', link: '/guide/abstractions/billboard' },
],
},
diff --git a/docs/guide/abstractions/image.md b/docs/guide/abstractions/image.md
new file mode 100644
index 000000000..2d522d77a
--- /dev/null
+++ b/docs/guide/abstractions/image.md
@@ -0,0 +1,33 @@
+# Image
+
+
+
+
+
+`` is a shader-based component that optionally loads then displays an image texture on a default plane or on your custom geometry.
+
+## Usage
+
+<<< @/.vitepress/theme/components/ImageDemo.vue
+
+## Props
+
+::: info
+`` is a THREE.Mesh and most Mesh attributes can be used as props on the component.
+:::
+
+| Prop | Description | Default |
+| :--------------- | :--------------------------------------------------- | ------------- |
+| `segments` | Number of divisions in the default geometry. | `1` |
+| `scale` | Scale of the geometry. `number \| [number, number]` | `1` |
+| `color` | Color multiplied into the image texture. | `'white'` |
+| `zoom` | Shrinks or enlarges the image texture. | `1` |
+| `radius` | Border radius applied to the image texture. (Intended for rectangular geometries. Use with `transparent`.) | `0` |
+| `grayscale` | Power of grayscale effect. 0 is off. 1 is full grayscale. | `0` |
+| `toneMapped` | Whether this material is tone mapped according to the renderers toneMapping settings. [See THREE.material.tonemapped](https://threejs.org/docs/?q=material#api/en/materials/Material.toneMapped) | `0` |
+| `transparent` | Whether the image material should be transparent. [See THREE.material.transparent](https://threejs.org/docs/?q=material#api/en/materials/Material.transparent) | `false` |
+| `transparent` | Whether the image material should be transparent. [See THREE.material.transparent](https://threejs.org/docs/?q=material#api/en/materials/Material.transparent) | `false` |
+| `opacity` | Opacity of the image material. [See THREE.material.transparent](https://threejs.org/docs/?q=material#api/en/materials/Material.transparent) | `1` |
+| `side` | THREE.Side of the image material. [See THREE.material.side](https://threejs.org/docs/?q=material#api/en/materials/Material.side) | `FrontSide` |
+| `texture` | Image texture to display on the geometry. | |
+| `url` | Image URL to load and display on the geometry. | |
diff --git a/playground/vue/src/pages/abstractions/ImageDemo.vue b/playground/vue/src/pages/abstractions/ImageDemo.vue
new file mode 100644
index 000000000..67706c588
--- /dev/null
+++ b/playground/vue/src/pages/abstractions/ImageDemo.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/vue/src/router/routes/abstractions.ts b/playground/vue/src/router/routes/abstractions.ts
index 04c686ac3..ad09164d2 100644
--- a/playground/vue/src/router/routes/abstractions.ts
+++ b/playground/vue/src/router/routes/abstractions.ts
@@ -64,6 +64,11 @@ export const abstractionsRoutes = [
name: 'AnimatedSprite',
component: () => import('../../pages/abstractions/AnimatedSpriteDemo.vue'),
},
+ {
+ path: '/abstractions/image',
+ name: 'Image',
+ component: () => import('../../pages/abstractions/ImageDemo.vue'),
+ },
{
path: '/abstractions/billboard',
name: 'Billboard',
diff --git a/src/core/abstractions/Image/ImageMaterial.vue b/src/core/abstractions/Image/ImageMaterial.vue
new file mode 100644
index 000000000..782ffc4ef
--- /dev/null
+++ b/src/core/abstractions/Image/ImageMaterial.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/src/core/abstractions/Image/ImageMaterialImpl.ts b/src/core/abstractions/Image/ImageMaterialImpl.ts
new file mode 100644
index 000000000..fcae35040
--- /dev/null
+++ b/src/core/abstractions/Image/ImageMaterialImpl.ts
@@ -0,0 +1,79 @@
+import { shaderMaterial } from './../../../utils/shaderMaterial'
+import { Color, Vector2 } from 'three'
+
+/**
+ * NOTE: Source:
+ * https://threejs.org/docs/?q=material#api/en/materials/Material.transparent
+ */
+const imageMaterialImpl = shaderMaterial(
+ {
+ color: /* @__PURE__ */ new Color('white'),
+ scale: /* @__PURE__ */ new Vector2(1, 1),
+ imageBounds: /* @__PURE__ */ new Vector2(1, 1),
+ resolution: 1024,
+ map: null,
+ zoom: 1,
+ radius: 0,
+ grayscale: 0,
+ opacity: 1,
+ },
+ /* glsl */ `
+ varying vec2 vUv;
+ varying vec2 vPos;
+ void main() {
+ gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.);
+ vUv = uv;
+ vPos = position.xy;
+ }
+ `,
+ /* glsl */ `
+ // mostly from https://gist.github.com/statico/df64c5d167362ecf7b34fca0b1459a44
+ varying vec2 vUv;
+ varying vec2 vPos;
+ uniform vec2 scale;
+ uniform vec2 imageBounds;
+ uniform float resolution;
+ uniform vec3 color;
+ uniform sampler2D map;
+ uniform float radius;
+ uniform float zoom;
+ uniform float grayscale;
+ uniform float opacity;
+ const vec3 luma = vec3(.299, 0.587, 0.114);
+ vec4 toGrayscale(vec4 color, float intensity) {
+ return vec4(mix(color.rgb, vec3(dot(color.rgb, luma)), intensity), color.a);
+ }
+ vec2 aspect(vec2 size) {
+ return size / min(size.x, size.y);
+ }
+
+ const float PI = 3.14159265;
+
+ // from https://iquilezles.org/articles/distfunctions
+ float udRoundBox( vec2 p, vec2 b, float r ) {
+ return length(max(abs(p)-b+r,0.0))-r;
+ }
+
+ void main() {
+ vec2 s = aspect(scale);
+ vec2 i = aspect(imageBounds);
+ float rs = s.x / s.y;
+ float ri = i.x / i.y;
+ vec2 new = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x);
+ vec2 offset = (rs < ri ? vec2((new.x - s.x) / 2.0, 0.0) : vec2(0.0, (new.y - s.y) / 2.0)) / new;
+ vec2 uv = vUv * s / new + offset;
+ vec2 zUv = (uv - vec2(0.5, 0.5)) / zoom + vec2(0.5, 0.5);
+
+ vec2 res = vec2(scale * resolution);
+ vec2 halfRes = 0.5 * res;
+ float b = udRoundBox(vUv.xy * res - halfRes, halfRes, resolution * radius);
+ vec3 a = mix(vec3(1.0,0.0,0.0), vec3(0.0,0.0,0.0), smoothstep(0.0, 1.0, b));
+ gl_FragColor = toGrayscale(texture2D(map, zUv) * vec4(color, opacity * a), grayscale);
+
+ #include
+ #include
+ }
+ `,
+)
+
+export default imageMaterialImpl
diff --git a/src/core/abstractions/Image/component.vue b/src/core/abstractions/Image/component.vue
new file mode 100644
index 000000000..1c966d249
--- /dev/null
+++ b/src/core/abstractions/Image/component.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/core/abstractions/index.ts b/src/core/abstractions/index.ts
index 553bbe06f..627987ebb 100644
--- a/src/core/abstractions/index.ts
+++ b/src/core/abstractions/index.ts
@@ -1,6 +1,7 @@
import AnimatedSprite from './AnimatedSprite/component.vue'
import Billboard from './Billboard.vue'
import { GlobalAudio } from './GlobalAudio'
+import Image from './Image/component.vue'
import Lensflare from './Lensflare/component.vue'
import Levioso from './Levioso.vue'
import MouseParallax from './MouseParallax.vue'
@@ -21,6 +22,7 @@ export {
Edges,
Fbo,
GlobalAudio,
+ Image,
Lensflare,
Levioso,
MouseParallax,
diff --git a/src/utils/shaderMaterial.ts b/src/utils/shaderMaterial.ts
new file mode 100644
index 000000000..6e6ff864a
--- /dev/null
+++ b/src/utils/shaderMaterial.ts
@@ -0,0 +1,82 @@
+/*
+MIT License
+
+Copyright (c) 2020 react-spring
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+import * as THREE from 'three'
+
+export function shaderMaterial(
+ uniforms: {
+ [name: string]:
+ | THREE.CubeTexture
+ | THREE.Texture
+ | Int32Array
+ | Float32Array
+ | THREE.Matrix4
+ | THREE.Matrix3
+ | THREE.Quaternion
+ | THREE.Vector4
+ | THREE.Vector3
+ | THREE.Vector2
+ | THREE.Color
+ | number
+ | boolean
+ | Array
+ | null
+ },
+ vertexShader: string,
+ fragmentShader: string,
+ onInit?: (material?: THREE.ShaderMaterial) => void,
+) {
+ const material = class extends THREE.ShaderMaterial {
+ public key: string = ''
+ constructor(parameters = {}) {
+ const entries = Object.entries(uniforms)
+ // Create unforms and shaders
+ super({
+ uniforms: entries.reduce((acc, [name, value]) => {
+ const uniform = THREE.UniformsUtils.clone({ [name]: { value } })
+ return {
+ ...acc,
+ ...uniform,
+ }
+ }, {}),
+ vertexShader,
+ fragmentShader,
+ })
+ // Create getter/setters
+ entries.forEach(([name]) =>
+ Object.defineProperty(this, name, {
+ get: () => this.uniforms[name].value,
+ set: v => (this.uniforms[name].value = v),
+ }),
+ )
+
+ // Assign parameters, this might include uniforms
+ Object.assign(this, parameters)
+ // Call onInit
+ if (onInit) { onInit(this) }
+ }
+ } as unknown as typeof THREE.ShaderMaterial & { key: string }
+ material.key = THREE.MathUtils.generateUUID()
+ return material
+}