diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1c386fab..5cb4e816 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -106,7 +106,8 @@ export default defineConfig({ { text: 'Stars', link: '/guide/staging/stars' }, { text: 'Smoke', link: '/guide/staging/smoke' }, { text: 'Contact Shadows', link: '/guide/staging/contact-shadows' }, - { text: 'Precipitation', link: '/guide/staging/precipitation' }], + { text: 'Precipitation', link: '/guide/staging/precipitation' }, + { text: 'Sparkles', link: '/guide/staging/sparkles' }], }, { text: 'Misc', diff --git a/docs/.vitepress/theme/components/SparklesDemo.vue b/docs/.vitepress/theme/components/SparklesDemo.vue new file mode 100644 index 00000000..baf645ee --- /dev/null +++ b/docs/.vitepress/theme/components/SparklesDemo.vue @@ -0,0 +1,15 @@ + + + diff --git a/docs/.vitepress/theme/components/SparklesDirectionalLightDemo.vue b/docs/.vitepress/theme/components/SparklesDirectionalLightDemo.vue new file mode 100644 index 00000000..ea8015c0 --- /dev/null +++ b/docs/.vitepress/theme/components/SparklesDirectionalLightDemo.vue @@ -0,0 +1,31 @@ + + + diff --git a/docs/.vitepress/theme/components/SparklesMixDemo.vue b/docs/.vitepress/theme/components/SparklesMixDemo.vue new file mode 100644 index 00000000..86afd28a --- /dev/null +++ b/docs/.vitepress/theme/components/SparklesMixDemo.vue @@ -0,0 +1,46 @@ + + + \ No newline at end of file diff --git a/docs/.vitepress/theme/components/SparklesSequenceDemo.vue b/docs/.vitepress/theme/components/SparklesSequenceDemo.vue new file mode 100644 index 00000000..ca8cba61 --- /dev/null +++ b/docs/.vitepress/theme/components/SparklesSequenceDemo.vue @@ -0,0 +1,25 @@ + + + diff --git a/docs/guide/staging/sparkles.md b/docs/guide/staging/sparkles.md new file mode 100644 index 00000000..f1aac385 --- /dev/null +++ b/docs/guide/staging/sparkles.md @@ -0,0 +1,103 @@ +# Sparkles + + + + + +`` makes sparkles on your geometry's vertices – optionally guided by a directional light. + +## Usage + +### Basic + +<<< @/.vitepress/theme/components/SparklesDemo.vue{3,11} + +### With TresDirectionalLight + +By default, sparkles appear on the up-facing vertices. However, you can pass a directional light to the component. Moving the directional light will cause "lit" vertices to emit sparkles. + + + + + +<<< @/.vitepress/theme/components/SparklesDirectionalLightDemo.vue{6,19-24,27} + +### Sequences + +All props beginning with `:sequence-` are used to define how a particle changes as it progresses [(See also: Mixes)](#mixes). `:sequence-` props are of the type `Gradient`, which can be any one of: + +* `T`: a single value +* `[T, T, T, ...]`: an evenly distributed series of values +* `[[number, T], [number, T], ...]`: an unevently distributed series of values, where `number` is a gradient "stop" from `0` to `1`. + +For example, all of these are acceptable values for `Gradient`: + +* `'red'` +* `['red', 'blue', 'green']` +* `[[0.1, 'red'], [0.25, 'blue'], [0.5, 'green']]` + + + + + +<<< @/.vitepress/theme/components/SparklesSequenceDemo.vue{12-16} + +### Mixes + +All props beginning with `:mix-` allow you to specify how a particle "progresses" through a corresponding `:sequence-` prop. E.g., `:mix-alpha` affects `:sequence-alpha`. + +* If the `:mix-` prop is `0.0`, 'progress' through the `:sequence-` is determined entirely by the light shining on the surface of the sparkling mesh.[1](#precisely) +* If the `:mix-` prop is `1.0`, 'progress' through the `:sequence-` is determined entirely by the particle's lifetime. + +1) More precisely, the value is determined by the dot product of the `directionalLight`'s inverted normalized position and each of the sparkling mesh's vertex normals. + + + + + +<<< @/.vitepress/theme/components/SparklesMixDemo.vue{9-11,35-39} + +## Props + +
NameDescription
mapType: Texture | string
Default: 'https://raw.githubusercontent.com/Tresjs/asset...

Texture or image path for individual sparkles
+
geometryType: Object3D | BufferGeometry
Default: undefined

Vertices of the geometry will be used to emit sparkles. Geometry normals are used for sparkles' traveling direction and for responding to the directional light prop.
+
    +
  • If provided, the component will use the passed geometry.
  • +
  • If no geometry is provided, the component will try to make a copy of the parent object's geometry.
  • +
  • If no parent geometry exists, the component will create and use an IcosphereGeometry.
  • +
+
directionalLightType: Object3D
Default: undefined

Particles "light up" when their normal "faces" the light. If no directionalLight is provided, the default "up" vector will be used.
+
lifetimeSecType: number
Default: 0.4

Particle lifetime in seconds
+
cooldownSecType: number
Default: 2.0

Particle cooldown in seconds – time between lifetime end and respawn
+
normalThresholdType: number
Default: 0.7

Number from 0-1 indicating how closely the particle needs to be faced towards the light to "light up". (Lower == more flexible)
+
noiseScaleType: number
Default: 3.0

Scale of the noise period (lower == more slowly cycling noise)
+
scaleNoiseType: number
Default: 1.0

Noise coefficient applied to particle scale
+
offsetNoiseType: number
Default: 0.1

Noise coefficient applied to particle offset
+
lifetimeNoiseType: number
Default: 0.0

Noise coefficient applied to particle lifetime
+
sizeType: number
Default: 1.0

Particle scale multiplier
+
alphaType: number
Default: 1.0

Opacity multiplier
+
offsetType: number
Default: 1.0

Offset multiplier
+
surfaceDistanceType: number
Default: 1.0

Surface distance multiplier
+
sequenceColorType: Gradient<TresColor>
Default: [[0.7, '#82dbc5'], [0.8, '#fbb03b']]

'Sequence' props: specify how a particle changes as it "progresses". See also "mix" props.
+Color sequence as particles progress
+
sequenceAlphaType: Gradient<number>
Default: [[0.0, 0.0], [0.10, 1.0], [0.5, 1.0], [0.9, 0.0]]

Opacity sequence as particles progress
+
sequenceOffsetType: Gradient<[number, number, number]>
Default: [0.0, 0.0, 0.0]

Distance sequence as particles progress
+
sequenceNoiseType: Gradient<[number, number, number]>
Default: [0.1, 0.1, 0.1]

Noise sequence as particles progress
+
sequenceSizeType: Gradient<number>
Default: [0.0, 1.0]

Size sequence as particles progress
+
sequenceSurfaceDistanceType: Gradient<number>
Default: [0.05, 0.08, 0.1]

Distance from surface (along normal) as particles progress
+
mixColorType: number
Default: 0.5

'mix*' props: A particle "progresses" with a mix of two factors:
+
    +
  • its normal "facing" the directionalLight
  • +
  • its lifetime
  • +
+'mix*' props specify the relationship between the two factors.
+How is a particle's progress for color calculated? (0: normal, 1: particle lifetime)
+
mixAlphaType: number
Default: 1.

How is a particle's progress for alpha calculated? (0: normal, 1: particle lifetime)
+
mixOffsetType: number
Default: 1.

How is a particle's progress for offset calculated? (0: normal, 1: particle lifetime)
+
mixSizeType: number
Default: 0.

How is a particle's progress for size calculated? (0: normal, 1: particle lifetime)
+
mixSurfaceDistanceType: number
Default: 1.

How is a particle's progress for surface distance calculated? (0: normal, 1: particle lifetime)
+
mixNoiseType: number
Default: 1.

How is a particle's progress for lifetime calculated? (0: normal, 1: particle lifetime)
+
blendingType: Blending
Default: AdditiveBlending

Material blending
+
transparentType: boolean
Default: true

Material transparency
+
depthWriteType: boolean
Default: false

Material depth write
+
5-39 \ No newline at end of file diff --git a/playground/src/pages/staging/SparklesDemo.vue b/playground/src/pages/staging/SparklesDemo.vue new file mode 100644 index 00000000..89a1c364 --- /dev/null +++ b/playground/src/pages/staging/SparklesDemo.vue @@ -0,0 +1,50 @@ + + + \ No newline at end of file diff --git a/playground/src/router/routes/staging.ts b/playground/src/router/routes/staging.ts index 69854b65..1d499892 100644 --- a/playground/src/router/routes/staging.ts +++ b/playground/src/router/routes/staging.ts @@ -35,4 +35,9 @@ export const stagingRoutes = [ name: 'Sky', component: () => import('../../pages/staging/SkyDemo.vue'), }, + { + path: '/staging/sparkles', + name: 'Sparkles', + component: () => import('../../pages/staging/SparklesDemo.vue'), + }, ] \ No newline at end of file diff --git a/src/core/staging/Sparkles/ShaderData.ts b/src/core/staging/Sparkles/ShaderData.ts new file mode 100644 index 00000000..5fe6b2b8 --- /dev/null +++ b/src/core/staging/Sparkles/ShaderData.ts @@ -0,0 +1,247 @@ +import { + ClampToEdgeWrapping, + DataTexture, + RGBAFormat, + UVMapping, + UnsignedByteType, +} from 'three' +import { clamp, mapLinear } from 'three/src/math/MathUtils' +import type { Ref, MaybeRef } from 'vue' +import { isRef, shallowRef, watch, triggerRef } from 'vue' +import { watchThrottled } from '@vueuse/core' +import type { + GradientTresColor, + GradientScalar, + GradientVectorFlexibleParams, +} from './../../../utils/Gradient' +import { + normalizeColorGradient, + normalizeScalarGradient, + normalizeFlexibleVector3Gradient, +} from './../../../utils/Gradient' + +export type CanvasGradientRenderer = ( + g: CanvasGradient, + entry: ShaderDataEntry +) => void + +export class ShaderData { + private entries: ShaderDataEntry[] + private resolution: number + + constructor(entries: ShaderDataEntry[], resolution: number) { + this.entries = entries + this.resolution = resolution + } + + useTexture() { + return new ShaderDataTexture(this.entries, this.resolution).use() + } +} + +export class ShaderDataEntry { + data: T + ref: Ref | null + name: string + valueMin: number + valueMax: number + suffix: string + renderToCanvasGradient: CanvasGradientRenderer + + constructor( + data: MaybeRef, + name: string, + valueMin: number, + valueMax: number, + suffix: string, + renderToCanvasGradient: ( + gradient: CanvasGradient, + data: ShaderDataEntry + ) => void, + ) { + this.data = isRef(data) ? data.value : data + this.ref = isRef(data) ? data : null + this.name = name + this.valueMin = valueMin + this.valueMax = valueMax + this.suffix = suffix + this.renderToCanvasGradient = renderToCanvasGradient + } +} + +export class ShaderDataEntryTresColorGradient extends ShaderDataEntry { + constructor( + data: MaybeRef, + name = 'color', + valueMin = 0, + valueMax = 1, + suffix = 'rgba', + renderToCanvasGradient = GradientTresColorRenderToCanvasGradient, + ) { + super(data, name, valueMin, valueMax, suffix, renderToCanvasGradient) + } +} + +export class ShaderDataEntryScalarGradient extends ShaderDataEntry { + constructor( + data: MaybeRef, + name = 'scalar', + valueMin = 0, + valueMax = 1, + suffix = 'x', + renderToCanvasGradient = GradientScalarRenderToCanvasGradient, + ) { + super(data, name, valueMin, valueMax, suffix, renderToCanvasGradient) + } +} + +export class ShaderDataEntryXyzGradient extends ShaderDataEntry { + constructor( + data: MaybeRef, + name = 'scalar3', + valueMin = 0, + valueMax = 1, + suffix = 'xyz', + renderToCanvasGradient = GradientXyzRenderToCanvasGradient, + ) { + super(data, name, valueMin, valueMax, suffix, renderToCanvasGradient) + } +} + +class ShaderDataTexture { + private entries: ShaderDataEntry[] + private size: number + private dirty = shallowRef(0) + private context: CanvasRenderingContext2D + + constructor(entries: ShaderDataEntry[], resolution: number) { + this.entries = entries + this.size = Math.max(resolution, entries.length) + + const canvas = document.createElement('canvas') + canvas.height = this.size + canvas.width = this.size + this.context = canvas.getContext('2d') as CanvasRenderingContext2D + } + + use() { + const texture = this.build() + const textureRef = shallowRef(texture) + + for (const entry of this.entries) { + if (entry.ref) { + watch(entry.ref, () => { + entry.data = entry.ref?.value + triggerRef(this.dirty) + }) + } + } + + watchThrottled( + this.dirty, + () => { + this.build(texture) + textureRef.value = texture + }, + { throttle: 1000 / 60 }, + ) + + return { + texture: textureRef, + dispose: () => texture.dispose(), + yFor: this.entries.reduce((obj, entry, i) => { + obj[entry.name] = (i + 0.5) / this.size + return obj + }, {} as Record), + } + } + + private build(recycledTexture?: DataTexture) { + this.entries.forEach((entry: ShaderDataEntry, i) => { + const gradient = this.context.createLinearGradient(0, i, this.size, i) + entry.renderToCanvasGradient(gradient, entry) + this.context.fillStyle = gradient + this.context.fillRect(0, i, this.size, 1) + }) + + if (recycledTexture) { + recycledTexture.source.data = this.context.getImageData( + 0, + 0, + this.size, + this.size, + ) + } + + const texture + = recycledTexture + ?? new DataTexture( + this.context.getImageData(0, 0, this.size, this.size).data, + this.size, + this.size, + RGBAFormat, + UnsignedByteType, + UVMapping, + ClampToEdgeWrapping, + ClampToEdgeWrapping, + ) + + texture.needsUpdate = true + + return texture + } +} + +function clampedMapLinear( + v: number, + minIn: number, + maxIn: number, + minOut: number, + maxOut: number, +) { + return mapLinear(clamp(v, minIn, maxIn), minIn, maxIn, minOut, maxOut) +} + +function GradientTresColorRenderToCanvasGradient( + g: CanvasGradient, + entry: ShaderDataEntryTresColorGradient, +) { + return normalizeColorGradient(entry.data).forEach(([offset, color]) => + g.addColorStop( + offset, + `rgb(${color.r * 255}, ${color.g * 255}, ${color.b * 255})`, + ), + ) +} + +function GradientScalarRenderToCanvasGradient( + g: CanvasGradient, + entry: ShaderDataEntryScalarGradient, +) { + return normalizeScalarGradient(entry.data).forEach(([offset, scalar]) => { + g.addColorStop( + offset, + `rgb(${clampedMapLinear( + scalar, + entry.valueMin, + entry.valueMax, + 0, + 255, + )}, 0, 0)`, + ) + }) +} + +function GradientXyzRenderToCanvasGradient( + g: CanvasGradient, + entry: ShaderDataEntryXyzGradient, +) { + return normalizeFlexibleVector3Gradient(entry.data).forEach(([offset, xyz]) => + g.addColorStop( + offset, + `rgb(${xyz.map(v => + clampedMapLinear(v, entry.valueMin, entry.valueMax, 0, 255), + )})`, + ), + ) +} diff --git a/src/core/staging/Sparkles/ShaderDataBuilder.ts b/src/core/staging/Sparkles/ShaderDataBuilder.ts new file mode 100644 index 00000000..f21a10ce --- /dev/null +++ b/src/core/staging/Sparkles/ShaderDataBuilder.ts @@ -0,0 +1,131 @@ +import type { MaybeRef } from 'vue' +import type { + GradientTresColor, + GradientScalar, + GradientVectorFlexibleParams, +} from './../../../utils/Gradient' +import type { + ShaderDataEntry, + CanvasGradientRenderer } from './ShaderData' +import { + ShaderDataEntryTresColorGradient, + ShaderDataEntryScalarGradient, + ShaderDataEntryXyzGradient, + ShaderData, +} from './ShaderData' + +const rgbaSuffixes = ['r', 'rg', 'rgb', 'rgba'] as const +const xyzwSuffixes = ['x', 'xy', 'xyz', 'xyzw'] as const +type ShaderSuffix = + | (typeof rgbaSuffixes)[number] + | (typeof xyzwSuffixes)[number] + +export default class ShaderDataBuilder { + private entries: ShaderDataEntry[] + private resolution: number + + constructor(resolution = 256) { + this.resolution = resolution + this.entries = [] + } + + withResolution(resolution: number) { + this.resolution = resolution + return this + } + + get add() { + return new ShaderDataBuilderAdd((entry: ShaderDataEntry) => + this.onAdd(entry), + ) + } + + build() { + return new ShaderData(this.entries, this.resolution) + } + + private onAdd(entry: ShaderDataEntry) { + this.entries.push(entry) + const entryBuilder = new ShaderDataEntryBuilder(entry, this) + return entryBuilder + } +} +class ShaderDataEntryBuilder { + private entry: ShaderDataEntry + private parent: ShaderDataBuilder + + constructor(entry: ShaderDataEntry, parent: ShaderDataBuilder) { + this.entry = entry + this.parent = parent + } + + id(s: string) { + this.entry.name = s + return this + } + + range(min: number, max: number) { + this.entry.valueMin = min + this.entry.valueMax = max + return this + } + + suffix(s: ShaderSuffix) { + this.entry.suffix = s + return this + } + + canvasGradientRenderer(fn: CanvasGradientRenderer) { + this.entry.renderToCanvasGradient = fn + return this + } + + /** + * Add another entry to the ShaderDataBuilder + */ + get add() { + return this.parent.add + } + + /** + * Finalize the ShaderDataBuilder + * @returns ShaderData + */ + build() { + return this.parent.build() + } +} + +class ShaderDataBuilderAdd { + private onAdd: (entry: ShaderDataEntry) => ShaderDataEntryBuilder + + constructor( + onAdd: (entry: ShaderDataEntry) => ShaderDataEntryBuilder, + ) { + this.onAdd = onAdd + } + + GradientTresColor(data: MaybeRef) { + return this.onAdd(new ShaderDataEntryTresColorGradient(data)) + } + + Gradient01(data: MaybeRef) { + return this.onAdd(new ShaderDataEntryScalarGradient(data, 'zeroOne', 0, 1)) + } + + GradientScalar(data: MaybeRef, min: number, max: number) { + return this.onAdd( + new ShaderDataEntryScalarGradient(data, 'scalar', min, max), + ) + } + + GradientXyz( + data: MaybeRef, + min: number, + max: number, + ) { + return this.onAdd( + new ShaderDataEntryXyzGradient(data, 'position', min, max), + ) + } +} diff --git a/src/core/staging/Sparkles/component.vue b/src/core/staging/Sparkles/component.vue new file mode 100644 index 00000000..bf4f2a57 --- /dev/null +++ b/src/core/staging/Sparkles/component.vue @@ -0,0 +1,407 @@ + + + \ No newline at end of file diff --git a/src/core/staging/Sparkles/useEmptyDataTexture.ts b/src/core/staging/Sparkles/useEmptyDataTexture.ts new file mode 100644 index 00000000..056651c9 --- /dev/null +++ b/src/core/staging/Sparkles/useEmptyDataTexture.ts @@ -0,0 +1,10 @@ +import { DataTexture } from 'three' + +let texture: DataTexture | null = null + +export default function useEmptyDataTexture(): DataTexture { + if (texture === null) { + texture = new DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1) + } + return texture +} \ No newline at end of file diff --git a/src/core/staging/index.ts b/src/core/staging/index.ts index 23db8ea4..99b5575f 100644 --- a/src/core/staging/index.ts +++ b/src/core/staging/index.ts @@ -1,17 +1,19 @@ import Environment from './useEnvironment/component.vue' import Backdrop from './Backdrop.vue' import ContactShadows from './ContactShadows.vue' -import Stars from './Stars.vue' import Precipitation from './Precipitation.vue' -import Smoke from './Smoke.vue' import Sky from './Sky.vue' +import Smoke from './Smoke.vue' +import Sparkles from './Sparkles/component.vue' +import Stars from './Stars.vue' export { Backdrop, ContactShadows, - Stars, - Precipitation, - Smoke, Environment, + Precipitation, Sky, + Smoke, + Sparkles, + Stars, } diff --git a/src/utils/Gradient.ts b/src/utils/Gradient.ts new file mode 100644 index 00000000..c5549a12 --- /dev/null +++ b/src/utils/Gradient.ts @@ -0,0 +1,104 @@ +import { Color } from 'three' +import type { TresColor } from '@tresjs/core' +import type { VectorFlexibleParams } from '@tresjs/core/dist/utils/normalize' +import { normalizeColor, normalizeVectorFlexibleParam } from '@tresjs/core' + +export type Gradient = T | T[] | NormalizedGradient +export type NormalizedGradient = [number, T][] +export type GradientTresColor = Gradient +export type GradientScalar = Gradient +export type GradientVectorFlexibleParams = Gradient + +export function normalizeColorGradient( + gradient: GradientTresColor, +): NormalizedGradient { + return normalizeGradient(gradient, { + normalizeValue: (input: TresColor) => normalizeColor(input), + getDefaultValue: () => new Color(0, 0, 0), + isSingleValue: (t: typeof gradient) => !Array.isArray(t), + isMultipleValues: (t: typeof gradient) => + Array.isArray(t) && (t.length === 0 || !Array.isArray(t[0])), + isMultipleValuesWithStops: (t: typeof gradient) => + Array.isArray(t) && t.length > 0 && Array.isArray(t[0]), + isEmpty: (t: typeof gradient) => Array.isArray(t) && t.length === 0, + }) +} + +function isVectorFlexibleParams(p: any) { + return ( + 'isVector3' in p + || (Array.isArray(p) && p.length > 0 && p.every(v => typeof v === 'number')) + ) +} + +export function normalizeFlexibleVector3Gradient( + gradient: GradientVectorFlexibleParams, +) { + return normalizeGradient>(gradient, { + normalizeValue: (input: VectorFlexibleParams) => + normalizeVectorFlexibleParam(input), + getDefaultValue: () => [0, 0, 0], + isSingleValue: (t: typeof gradient) => isVectorFlexibleParams(t), + isMultipleValues: (t: typeof gradient) => + Array.isArray(t) && t.length > 0 && isVectorFlexibleParams(t[0]), + isMultipleValuesWithStops: (t: typeof gradient) => + Array.isArray(t) + && t.length > 0 + && Array.isArray(t[0]) + && t[0].length === 2 + && isVectorFlexibleParams(t[0][1]), + isEmpty: (t: typeof gradient) => Array.isArray(t) && t.length === 0, + }) +} +export function normalizeScalarGradient( + gradient: GradientScalar, +): NormalizedGradient { + return normalizeGradient(gradient, { + normalizeValue: (input: number) => input, + getDefaultValue: () => 1, + isSingleValue: (t: typeof gradient) => + !Array.isArray(t) && typeof t !== 'undefined', + isMultipleValues: (t: typeof gradient) => + Array.isArray(t) && (t.length === 0 || !Array.isArray(t[0])), + isMultipleValuesWithStops: (t: typeof gradient) => + Array.isArray(t) && t.length > 0 && Array.isArray(t[0]), + isEmpty: (t: typeof gradient) => Array.isArray(t) && t.length === 0, + }) +} + +interface NormalizeConfig { + normalizeValue: (t: T) => U + getDefaultValue: () => U + isSingleValue: (t: Gradient) => boolean + isMultipleValues: (t: Gradient) => boolean + isMultipleValuesWithStops: (t: Gradient) => boolean + isEmpty: (t: Gradient) => boolean +} + +function normalizeGradient( + gradient: Gradient, + config: NormalizeConfig, +): NormalizedGradient { + const { normalizeValue, getDefaultValue, isEmpty } = config + const isSingleValue = (t: Gradient): t is T => config.isSingleValue(t) + const isMultipleValues = (t: Gradient): t is T[] => + config.isMultipleValues(t) + const isMultipleValuesWithStops = (t: Gradient): t is Array<[number, T]> => + config.isMultipleValuesWithStops(t) + + if (isEmpty(gradient)) { + return [[0, getDefaultValue()]] + } + else if (isSingleValue(gradient)) { + return [[0, normalizeValue(gradient as T)]] + } + else if (isMultipleValues(gradient)) { + const step = gradient.length > 1 ? 1 / (gradient.length - 1) : 1 + return gradient.map((input, i) => [step * i, normalizeValue(input)]) + } + else if (isMultipleValuesWithStops(gradient)) { + return gradient.map(([u, v], _) => [u, normalizeValue(v)]) + } + + return [[0, getDefaultValue()]] +}