|
| 1 | +import { Component, computed, CUSTOM_ELEMENTS_SCHEMA, inject, Input, isSignal, Signal } from '@angular/core'; |
| 2 | +import { |
| 3 | + extend, |
| 4 | + injectBeforeRender, |
| 5 | + injectNgtRef, |
| 6 | + NgtArgs, |
| 7 | + NgtSignalStore, |
| 8 | + NgtStore, |
| 9 | + type NgtRenderState, |
| 10 | +} from 'angular-three'; |
| 11 | +import { SparklesMaterial } from 'angular-three-soba/shaders'; |
| 12 | +import * as THREE from 'three'; |
| 13 | +import { BufferAttribute, BufferGeometry, Color, MathUtils, Points, Vector2, Vector3, Vector4 } from 'three'; |
| 14 | + |
| 15 | +extend({ SparklesMaterial, Points, BufferGeometry, BufferAttribute }); |
| 16 | + |
| 17 | +const isFloat32Array = (def: any): def is Float32Array => def && (def as Float32Array).constructor === Float32Array; |
| 18 | +const expandColor = (v: THREE.Color) => [v.r, v.g, v.b]; |
| 19 | +const isVector = (v: any): v is THREE.Vector2 | THREE.Vector3 | THREE.Vector4 => |
| 20 | + v instanceof Vector2 || v instanceof Vector3 || v instanceof Vector4; |
| 21 | +const normalizeVector = (v: any): number[] => { |
| 22 | + if (Array.isArray(v)) return v; |
| 23 | + else if (isVector(v)) return v.toArray(); |
| 24 | + return [v, v, v] as number[]; |
| 25 | +}; |
| 26 | + |
| 27 | +function usePropAsIsOrAsAttribute<T = any>(count: number, prop?: T | Float32Array, setDefault?: (v: T) => number) { |
| 28 | + if (prop !== undefined) { |
| 29 | + if (isFloat32Array(prop)) { |
| 30 | + return prop as Float32Array; |
| 31 | + } else { |
| 32 | + if (prop instanceof Color) { |
| 33 | + const a = Array.from({ length: count * 3 }, () => expandColor(prop)).flat(); |
| 34 | + return Float32Array.from(a); |
| 35 | + } else if (isVector(prop) || Array.isArray(prop)) { |
| 36 | + const a = Array.from({ length: count * 3 }, () => normalizeVector(prop)).flat(); |
| 37 | + return Float32Array.from(a); |
| 38 | + } |
| 39 | + return Float32Array.from({ length: count }, () => prop as unknown as number); |
| 40 | + } |
| 41 | + } |
| 42 | + return Float32Array.from({ length: count }, setDefault!); |
| 43 | +} |
| 44 | + |
| 45 | +export interface NgtsSparklesState { |
| 46 | + /** Number of particles (default: 100) */ |
| 47 | + count: number; |
| 48 | + /** Speed of particles (default: 1) */ |
| 49 | + speed: number | Float32Array; |
| 50 | + /** Opacity of particles (default: 1) */ |
| 51 | + opacity: number | Float32Array; |
| 52 | + /** Color of particles (default: 100) */ |
| 53 | + color?: THREE.ColorRepresentation | Float32Array; |
| 54 | + /** Size of particles (default: randomized between 0 and 1) */ |
| 55 | + size?: number | Float32Array; |
| 56 | + /** The space the particles occupy (default: 1) */ |
| 57 | + scale: number | [number, number, number] | THREE.Vector3; |
| 58 | + /** Movement factor (default: 1) */ |
| 59 | + noise: number | [number, number, number] | THREE.Vector3 | Float32Array; |
| 60 | +} |
| 61 | + |
| 62 | +@Component({ |
| 63 | + selector: 'ngts-sparkles', |
| 64 | + standalone: true, |
| 65 | + template: ` |
| 66 | + <ngt-points ngtCompount [ref]="pointsRef"> |
| 67 | + <ngt-buffer-geometry> |
| 68 | + <ngt-buffer-attribute *args="[positions(), 3]" attach="attributes.position" /> |
| 69 | + <ngt-buffer-attribute *args="[sizes(), 1]" attach="attributes.size" /> |
| 70 | + <ngt-buffer-attribute *args="[opacities(), 1]" attach="attributes.opacity" /> |
| 71 | + <ngt-buffer-attribute *args="[speeds(), 1]" attach="attributes.speed" /> |
| 72 | + <ngt-buffer-attribute *args="[colors(), 3]" attach="attributes.color" /> |
| 73 | + <ngt-buffer-attribute *args="[noises(), 3]" attach="attributes.noise" /> |
| 74 | + </ngt-buffer-geometry> |
| 75 | + <ngt-sparkles-material [ref]="materialRef" [transparent]="true" [depthWrite]="false" [pixelRatio]="dpr()" /> |
| 76 | + </ngt-points> |
| 77 | + `, |
| 78 | + imports: [NgtArgs], |
| 79 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 80 | +}) |
| 81 | +export class NgtsSparkles extends NgtSignalStore<NgtsSparklesState> { |
| 82 | + @Input() pointsRef = injectNgtRef<Points>(); |
| 83 | + |
| 84 | + /** Number of particles (default: 100) */ |
| 85 | + @Input() set count(count: number) { |
| 86 | + this.set({ count }); |
| 87 | + } |
| 88 | + |
| 89 | + /** Speed of particles (default: 1) */ |
| 90 | + @Input() set speed(speed: number | Float32Array) { |
| 91 | + this.set({ speed }); |
| 92 | + } |
| 93 | + |
| 94 | + /** Opacity of particles (default: 1) */ |
| 95 | + @Input() set opacity(opacity: number | Float32Array) { |
| 96 | + this.set({ opacity }); |
| 97 | + } |
| 98 | + |
| 99 | + /** Color of particles (default: 100) */ |
| 100 | + @Input() set color(color: THREE.ColorRepresentation | Float32Array) { |
| 101 | + this.set({ color }); |
| 102 | + } |
| 103 | + |
| 104 | + /** Size of particles (default: randomized between 0 and 1) */ |
| 105 | + @Input() set size(size: number | Float32Array) { |
| 106 | + this.set({ size }); |
| 107 | + } |
| 108 | + |
| 109 | + /** The space the particles occupy (default: 1) */ |
| 110 | + @Input() set scale(scale: number | [number, number, number] | THREE.Vector3) { |
| 111 | + this.set({ scale }); |
| 112 | + } |
| 113 | + |
| 114 | + /** Movement factor (default: 1) */ |
| 115 | + @Input() set noise(noise: number | [number, number, number] | THREE.Vector3 | Float32Array) { |
| 116 | + this.set({ noise }); |
| 117 | + } |
| 118 | + |
| 119 | + readonly #store = inject(NgtStore); |
| 120 | + |
| 121 | + readonly #count = this.select('count'); |
| 122 | + readonly #scale = this.select('scale'); |
| 123 | + readonly #color = this.select('color'); |
| 124 | + |
| 125 | + readonly materialRef = injectNgtRef<InstanceType<typeof SparklesMaterial>>(); |
| 126 | + |
| 127 | + readonly dpr = this.#store.select('viewport', 'dpr'); |
| 128 | + readonly positions = computed(() => |
| 129 | + Float32Array.from( |
| 130 | + Array.from({ length: this.#count() }, () => |
| 131 | + normalizeVector(this.#scale()).map(MathUtils.randFloatSpread) |
| 132 | + ).flat() |
| 133 | + ) |
| 134 | + ); |
| 135 | + |
| 136 | + readonly sizes = this.#getComputed('size', this.#count, Math.random); |
| 137 | + readonly opacities = this.#getComputed('opacity', this.#count); |
| 138 | + readonly speeds = this.#getComputed('speed', this.#count); |
| 139 | + readonly noises = this.#getComputed('noise', () => this.#count() * 3); |
| 140 | + readonly colors = this.#getComputed( |
| 141 | + computed(() => { |
| 142 | + const color = this.#color(); |
| 143 | + return !isFloat32Array(color) ? new THREE.Color(color) : color; |
| 144 | + }), |
| 145 | + () => (this.#color() === undefined ? this.#count() * 3 : this.#count()), |
| 146 | + () => 1 |
| 147 | + ); |
| 148 | + |
| 149 | + #getComputed<TKey extends keyof NgtsSparklesState>( |
| 150 | + nameOrComputed: TKey | Signal<NgtsSparklesState[TKey]>, |
| 151 | + count: () => number, |
| 152 | + setDefault?: (value: NgtsSparklesState[TKey]) => number |
| 153 | + ) { |
| 154 | + const value = |
| 155 | + typeof nameOrComputed !== 'string' && isSignal(nameOrComputed) |
| 156 | + ? nameOrComputed |
| 157 | + : this.select(nameOrComputed); |
| 158 | + return computed(() => usePropAsIsOrAsAttribute(count(), value(), setDefault)); |
| 159 | + } |
| 160 | + |
| 161 | + constructor() { |
| 162 | + super({ |
| 163 | + noise: 1, |
| 164 | + count: 100, |
| 165 | + speed: 1, |
| 166 | + opacity: 1, |
| 167 | + scale: 1, |
| 168 | + }); |
| 169 | + injectBeforeRender(this.#onBeforeRender.bind(this)); |
| 170 | + } |
| 171 | + |
| 172 | + #onBeforeRender({ clock }: NgtRenderState) { |
| 173 | + if (!this.materialRef.nativeElement) return; |
| 174 | + this.materialRef.nativeElement.uniforms['time'].value = clock.elapsedTime; |
| 175 | + } |
| 176 | +} |
0 commit comments