@ev-ry/fx 0.1.0-rc.3 → 0.1.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,195 +1,195 @@
1
- import {particleCenters} from './particle-centers.js';
2
- import {textEditMotions} from './text-edit-motions.js';
3
- import {normalizeTextEffectOptions,cloneTextEffectOptions,textEffectOptionShader} from './text-effect-options.js';
4
- import {textRecipeUniformShader} from './text-motion-recipes.js';
5
- import {textEffectPathShader,textEffectApplicationShader} from './text-effect-path.js';
6
- import {textMotionPrimitives} from './text-motion-primitives.js';
7
- import {ensureTextMotionContour} from './text-motion-contour.js';
8
- import {ensureTriangleMotionFrame} from './triangle-motion-frame.js';
9
-
10
-
11
- // Rectangle-driven triangle animation shared by text edits and image surfaces.
12
- // Editing/range mapping stays in TextEditEffect; existing clocks and paths are unchanged.
13
- export class TriangleEffect {
14
- constructor(THREE,{duration,departing=false,mode='dust-wind',settings={}}={}){
15
- const motion=textEditMotions[mode];if(!motion)throw TypeError('Invalid text motion');
16
- duration??=motion.duration;this.mode=mode;
17
- this.THREE=THREE;this.duration=duration;this.departing=departing;this.active=false;this.jobs=[];this.ghosts=[];this.text=null;this.runs=0;this.exits=0;this.sequence=0;this.lastTime=null;
18
- this.settledJobs=[];this.boundView=null;this.boundVersion=null;
19
- this.uniforms={dustEnabled:{value:0},dustMotion:{value:motion.id},dustMaskOnly:{value:departing?1:0},dustEm:{value:1},dustSeconds:{value:duration/1000},dustCount:{value:0},dustWidth:{value:1},dustData:{value:null}};
20
- this.allocate(1);
21
- this.materials=new Set();
22
- this.uniforms.dustTransform={value:new THREE.Vector3(1,1,1)};
23
- this.uniforms.dustFormation={value:0};this.uniforms.dustChaos={value:1};this.uniforms.dustBounce={value:0};
24
- this.uniforms.dustRecipeDirection={value:new THREE.Vector2(1,0)};
25
- this.uniforms.dustRecipePalette={value:new THREE.Vector3(-1,-1,-1)};
26
- for(const name of ['Depth','Spin','Sway','Shape','Glow','Rebound','Pace'])this.uniforms['dustRecipe'+name]={value:1};
27
- this.configure({...settings,...(duration===motion.duration?{}:{duration})});
28
- }
29
- get pending(){return this.jobs.some(job=>job.started===null);}
30
- get exitMode(){return this.settings.exitEffect==='same'?this.mode:this.settings.exitEffect;}
31
- setMode(mode){
32
- const motion=textEditMotions[mode];if(!motion)throw TypeError('Invalid text motion');
33
- if(mode===this.mode)return;
34
- this.cancel();this.mode=mode;
35
- this.uniforms.dustMotion.value=motion.id;this.configure();
36
- for(const material of this.materials)this.syncMaterialDefines(material);
37
- }
38
- configure(settings={}){
39
- const next=normalizeTextEffectOptions(settings,this.mode);
40
- if(this.settings&&JSON.stringify(next)===JSON.stringify(this.settings))return;
41
- this.cancel();this.settings=next;this.duration=next.duration;
42
- this.uniforms.dustSeconds.value=next.duration/1000;
43
- const sx=next.flipX!==(this.departing&&next.exitFlipX)?-1:1,sy=next.flipY!==(this.departing&&next.exitFlipY)?-1:1;
44
- this.uniforms.dustTransform.value.set(sx*next.motion*next.horizontal,sy*next.motion*next.vertical,next.motion);
45
- this.uniforms.dustFormation.value=next.formation;this.uniforms.dustChaos.value=next.chaos*2;this.uniforms.dustBounce.value=next.bounceLines?1:0;
46
- const angle=next.recipe.angle*Math.PI/180;
47
- const snap=v=>Math.abs(v)<1e-12?0:v;
48
- this.uniforms.dustRecipeDirection.value.set(snap(Math.cos(angle)),snap(Math.sin(angle)));
49
- for(const name of ['Depth','Spin','Sway','Shape','Glow','Rebound','Pace'])this.uniforms['dustRecipe'+name].value=next.recipe[name.toLowerCase()];
50
- this.uniforms.dustRecipePalette.value.fromArray({original:[-1,-1,-1],cool:[.3,.7,1],warm:[1,.5,.2],mint:[.3,1,.7]}[next.recipe.palette]);
51
- this.characterView=null;
52
- for(const material of this.materials)this.syncMaterialDefines(material);
53
- }
54
- syncMaterialDefines(material){
55
- const scale=this.uniforms.dustTransform.value;
56
- const flags={THD_EDIT_MODE:textEditMotions[this.mode].id,THD_EDIT_CHARACTER_FRAME:textEditMotions[this.mode].characterFrame||material.userData.triangleMotionFrame?1:0,THD_EDIT_ADJUST:scale.x!==1||scale.y!==1||scale.z!==1?1:0,THD_EDIT_BOUNCE:this.settings.bounceLines?1:0};
57
- if(Object.entries(flags).some(([key,value])=>material.defines?.[key]!==value)){material.defines={...material.defines,...flags};material.needsUpdate=true;}
58
- }
59
- randomSeed(){return this.settings.seed===null?Math.random():this.settings.seed/4294967296;}
60
- allocate(count){
61
- if(this.capacity>=count)return;
62
- this.capacity=2**Math.ceil(Math.log2(Math.max(1,count)));
63
- this.uniforms.dustData.value?.dispose();
64
- const THREE=this.THREE,texture=new THREE.DataTexture(new Float32Array(this.capacity*8),this.capacity,2,THREE.RGBAFormat,THREE.FloatType);
65
- texture.minFilter=texture.magFilter=THREE.NearestFilter;texture.generateMipmaps=false;
66
- this.uniforms.dustData.value=texture;this.uniforms.dustWidth.value=this.capacity;
67
- }
68
- decorate(view){
69
- const THREE=this.THREE;
70
- this.materials=new Set(Array.from(view.sharedGroups?.values()||[],({mesh})=>mesh.material));
71
- for(const {mesh} of view.sharedGroups?.values()||[]){
72
- const geometry=mesh.geometry;
73
- if(this.settings.bounceLines&&!geometry.getAttribute('dustCornerA')){
74
- const a=geometry.getAttribute('position'),corners=[new Float32Array(a.count*3),new Float32Array(a.count*3),new Float32Array(a.count*3)];
75
- for(let i=0;i<a.count;i+=3)for(let c=0;c<3;c++)for(let j=0;j<3;j++)corners[c].set(a.array.subarray((i+c)*3,(i+c+1)*3),(i+j)*3);
76
- corners.forEach((values,i)=>geometry.setAttribute(['dustCornerA','dustCornerB','dustCornerC'][i],new THREE.BufferAttribute(values,3)));
77
- }
78
- if(textEditMotions[this.mode].contour)ensureTextMotionContour(THREE,geometry);
79
- particleCenters(THREE,geometry,this.settings.particleShape);
80
- const material=mesh.material;
81
- // A surface supplies one local frame; text continues to supply measured
82
- // per-character attributes. Constant attributes avoid repeating image
83
- // bounds for every vertex and preserve the same path/bounce evaluator.
84
- const frame=view.motionFrame;
85
- material.userData.triangleMotionFrame=!!frame;
86
- if(frame){
87
- material.defaultAttributeValues.dustCharacterFrame=[frame.x,frame.y,frame.width,frame.height];
88
- material.defaultAttributeValues.dustFloor=[frame.y,0];
89
- material.defaultAttributeValues.dustCeiling=[frame.y+frame.height,0,0,0];
90
- const motion=textEditMotions[this.mode];
91
- ensureTriangleMotionFrame(THREE,geometry,frame,{side:motion.characterCenters,floor:motion.characterFloor});
92
- }
93
- this.syncMaterialDefines(material);
94
- if(material.userData.dustWind)continue;
95
- material.defaultAttributeValues.dustEdge=[1e6,1e6,1e6];
96
- material.defaultAttributeValues.dustCharacterSide=[0];
97
- if(!frame){
98
- material.defaultAttributeValues.dustCharacterFrame=[0,0,0,0];
99
- material.defaultAttributeValues.dustFloor=[0,0];
100
- material.defaultAttributeValues.dustCeiling=[0,0,0,0];
101
- }
102
- for(const name of ['dustCornerA','dustCornerB','dustCornerC'])material.defaultAttributeValues[name]=[0,0,0];
103
- material.userData.dustSource={vertexShader:material.vertexShader,fragmentShader:material.fragmentShader};
104
- material.userData.dustWind=true;Object.assign(material.uniforms,this.uniforms);
105
- material.vertexShader=`attribute vec2 dustCenter; attribute vec3 dustEdge; attribute float dustCharacterSide; attribute vec2 dustFloor; uniform float dustEnabled; uniform float dustMotion; uniform float dustMaskOnly; uniform float dustEm; uniform float dustSeconds; uniform int dustCount; uniform float dustWidth; uniform sampler2D dustData; varying float dustOpacity; varying float dustGlow; varying vec3 dustGlowColor; varying vec3 dustContour; varying float dustOutline;\n${textMotionPrimitives}\n`+material.vertexShader;
106
- // Function declarations follow the attributes/uniforms they use.
107
- const sourceMarker=material.vertexShader.indexOf('void main');
108
- material.vertexShader=material.vertexShader.slice(0,sourceMarker)+`attribute vec4 dustCharacterFrame; attribute vec4 dustCeiling; attribute vec3 dustCornerA; attribute vec3 dustCornerB; attribute vec3 dustCornerC; uniform vec3 dustTransform; uniform float dustFormation; uniform float dustChaos; uniform float dustBounce;\n${textRecipeUniformShader}\n${textEffectOptionShader}\n${textEffectPathShader}\n`+material.vertexShader.slice(sourceMarker);
109
- const marker=material.vertexShader.includes('/* THD_TRIANGLE_MOTION */')?'/* THD_TRIANGLE_MOTION */':'float sampleIndex =';
110
- material.vertexShader=material.vertexShader.replace(marker,`
111
- dustOpacity=1.0-dustMaskOnly;
112
- dustGlow=0.0;
113
- dustGlowColor=vec3(1.0);
114
- dustContour=dustEdge;
115
- dustOutline=0.0;
116
- vec2 dustTarget=dustCenter+glyphOffset;
117
- if(dustEnabled>0.5){
118
- for(int k=0;k<dustCount;k++){
119
- float u=(float(k)+0.5)/dustWidth;
120
- vec4 r=texture2D(dustData,vec2(u,0.25));
121
- if(dustTarget.x>=r.x&&dustTarget.x<=r.z&&dustTarget.y>=r.y&&dustTarget.y<=r.w){
122
- vec4 clock=texture2D(dustData,vec2(u,0.75));
123
- vec2 particle=(dustTarget-r.xy)/dustEm;
124
- float seed=fract(sin(dot(particle,vec2(127.1,311.7))+clock.w*53.0)*43758.5453);
125
- float drift=fract(sin(dot(particle,vec2(269.5,183.3))+clock.w*91.7)*43758.5453);
126
- float pace=fract(sin(dot(particle,vec2(419.2,371.9))+clock.w*73.0)*43758.5453);
127
- vec2 travel=thdParticleTravel(seed,drift,pace,dustSeconds);
128
- travel.y=thdFormationSeconds(travel.y,dustSeconds,dustFormation);
129
- float t=thdMotionPhase(clock,travel.y,dustSeconds);
130
- #ifdef THD_HYBRID_GLOBAL_ESCAPE
131
- hybridEscaping=1.0-step(0.99999,t);
132
- #endif
133
- #ifdef THD_RASTER_TEXTURE
134
- vRasterQuality=smoothstep(0.9,1.0,t);
135
- #endif
136
- if(dustChaos!=1.0){
137
- seed=clamp(mix(0.5,seed,dustChaos),0.0,1.0);
138
- drift=clamp(mix(0.5,drift,dustChaos),0.0,1.0);
139
- pace=clamp(mix(0.5,pace,dustChaos),0.0,1.0);
140
- }
141
- float remain=1.0-t;
142
- dustOpacity=1.0;
143
- ${textEffectApplicationShader}
144
- dustOpacity*=thdParticleAppearance(t,fract(seed*17.17+drift*3.13));
145
- p.z=thdFrontDepth(position.z,p.z);
146
- break;
147
- }
148
- }
149
- }
150
- ${marker==='float sampleIndex ='?marker:''}`);
151
- material.fragmentShader='uniform float dustEm; varying float dustOpacity; varying float dustGlow; varying vec3 dustGlowColor; varying vec3 dustContour; varying float dustOutline;\n'+material.fragmentShader;
152
- material.fragmentShader=material.fragmentShader.replace('coverage);',`coverage * dustOpacity);
153
- gl_FragColor.rgb=mix(gl_FragColor.rgb,dustGlowColor,clamp(dustGlow,0.0,1.0));
154
- if(dustOutline>0.0){
155
- float edge=min(dustContour.x,min(dustContour.y,dustContour.z));
156
- float border=1.0-smoothstep(dustEm*0.018,dustEm*0.018+max(fwidth(edge),0.00001),edge);
157
- gl_FragColor.a*=mix(1.0,border,dustOutline);
158
- }`);material.needsUpdate=true;
159
- }
160
- }
161
- playRegion(view,rectangle,unit,now,{departing=false,seed=this.randomSeed(),fromAge=1}={}){
162
- const settings=this.settings;
163
- this.cancel();this.departing=departing;this.uniforms.dustMaskOnly.value=departing?1:0;
164
- this.settings=null;this.configure(settings);
165
- this.decorate(view);this.uniforms.dustEm.value=unit;
166
- this.jobs=[{id:++this.sequence,seed,started:now,fromAge,rectangles:[rectangle]}];
167
- this.runs++;return this.step(now);
168
- }
169
- step(now,reducedMotion=false){
170
- this.lastTime=now;
171
- if(reducedMotion){this.cancel();return false;}
172
- this.jobs=this.jobs.filter(job=>{
173
- if(job.started===null||now-job.started<this.duration)return true;
174
- if(!this.departing)this.settledJobs.push(job);
175
- return false;
176
- });
177
- const rectangles=this.jobs.filter(job=>job.started!==null).flatMap(job=>job.rectangles.map(rect=>({rect,age:Math.max(0,(now-job.started)/this.duration),seed:job.seed||0,fromAge:job.fromAge??1})));
178
- this.allocate(rectangles.length);
179
- const data=this.uniforms.dustData.value.image.data;
180
- rectangles.forEach(({rect:r,age,seed,fromAge},i)=>{
181
- data.set([r.x,r.y,r.x+r.width,r.y+r.height],i*4);
182
- data.set([age,r.direction==='rtl'?-1:1,this.departing?1+fromAge:0,seed],(this.capacity+i)*4);
183
- });
184
- this.uniforms.dustData.value.needsUpdate=true;this.uniforms.dustCount.value=rectangles.length;
185
- this.ghosts=this.ghosts.filter(ghost=>{
186
- // First displayed frame is phase zero, even if shaping delayed that frame.
187
- for(const job of ghost.effect.jobs)job.started??=now;
188
- if(ghost.effect.step(now))return true;ghost.release();return false;
189
- });
190
- this.active=rectangles.length>0||this.ghosts.length>0;this.uniforms.dustEnabled.value=rectangles.length?1:0;return this.active;
191
- }
192
- cancel(){for(const ghost of this.ghosts)ghost.release();this.ghosts=[];this.jobs=[];this.settledJobs=[];this.boundView=null;this.boundVersion=null;this.text=null;this.active=false;this.uniforms.dustEnabled.value=0;this.uniforms.dustCount.value=0;}
193
- dispose(){this.cancel();this.materials.clear();this.uniforms.dustData.value.dispose();}
194
- stats(){return {mode:this.mode,exitMode:this.exitMode,duration:this.duration,settings:cloneTextEffectOptions(this.settings),active:this.active,pending:this.pending,runs:this.runs,exits:this.exits,departures:this.ghosts.length,departureModes:this.ghosts.map(g=>g.effect.mode),rectangles:this.uniforms.dustCount.value,batches:this.jobs.map(({id,range,started})=>({id,range,started}))};}
195
- }
1
+ import {particleCenters} from './particle-centers.js';
2
+ import {textEditMotions} from './text-edit-motions.js';
3
+ import {normalizeTextEffectOptions,cloneTextEffectOptions,textEffectOptionShader} from './text-effect-options.js';
4
+ import {textRecipeUniformShader} from './text-motion-recipes.js';
5
+ import {textEffectPathShader,textEffectApplicationShader} from './text-effect-path.js';
6
+ import {textMotionPrimitives} from './text-motion-primitives.js';
7
+ import {ensureTextMotionContour} from './text-motion-contour.js';
8
+ import {ensureTriangleMotionFrame} from './triangle-motion-frame.js';
9
+
10
+
11
+ // Rectangle-driven triangle animation shared by text edits and image surfaces.
12
+ // Editing/range mapping stays in TextEditEffect; existing clocks and paths are unchanged.
13
+ export class TriangleEffect {
14
+ constructor(THREE,{duration,departing=false,mode='dust-wind',settings={}}={}){
15
+ const motion=textEditMotions[mode];if(!motion)throw TypeError('Invalid text motion');
16
+ duration??=motion.duration;this.mode=mode;
17
+ this.THREE=THREE;this.duration=duration;this.departing=departing;this.active=false;this.jobs=[];this.ghosts=[];this.text=null;this.runs=0;this.exits=0;this.sequence=0;this.lastTime=null;
18
+ this.settledJobs=[];this.boundView=null;this.boundVersion=null;
19
+ this.uniforms={dustEnabled:{value:0},dustMotion:{value:motion.id},dustMaskOnly:{value:departing?1:0},dustEm:{value:1},dustSeconds:{value:duration/1000},dustCount:{value:0},dustWidth:{value:1},dustData:{value:null}};
20
+ this.allocate(1);
21
+ this.materials=new Set();
22
+ this.uniforms.dustTransform={value:new THREE.Vector3(1,1,1)};
23
+ this.uniforms.dustFormation={value:0};this.uniforms.dustChaos={value:1};this.uniforms.dustBounce={value:0};
24
+ this.uniforms.dustRecipeDirection={value:new THREE.Vector2(1,0)};
25
+ this.uniforms.dustRecipePalette={value:new THREE.Vector3(-1,-1,-1)};
26
+ for(const name of ['Depth','Spin','Sway','Shape','Glow','Rebound','Pace'])this.uniforms['dustRecipe'+name]={value:1};
27
+ this.configure({...settings,...(duration===motion.duration?{}:{duration})});
28
+ }
29
+ get pending(){return this.jobs.some(job=>job.started===null);}
30
+ get exitMode(){return this.settings.exitEffect==='same'?this.mode:this.settings.exitEffect;}
31
+ setMode(mode){
32
+ const motion=textEditMotions[mode];if(!motion)throw TypeError('Invalid text motion');
33
+ if(mode===this.mode)return;
34
+ this.cancel();this.mode=mode;
35
+ this.uniforms.dustMotion.value=motion.id;this.configure();
36
+ for(const material of this.materials)this.syncMaterialDefines(material);
37
+ }
38
+ configure(settings={}){
39
+ const next=normalizeTextEffectOptions(settings,this.mode);
40
+ if(this.settings&&JSON.stringify(next)===JSON.stringify(this.settings))return;
41
+ this.cancel();this.settings=next;this.duration=next.duration;
42
+ this.uniforms.dustSeconds.value=next.duration/1000;
43
+ const sx=next.flipX!==(this.departing&&next.exitFlipX)?-1:1,sy=next.flipY!==(this.departing&&next.exitFlipY)?-1:1;
44
+ this.uniforms.dustTransform.value.set(sx*next.motion*next.horizontal,sy*next.motion*next.vertical,next.motion);
45
+ this.uniforms.dustFormation.value=next.formation;this.uniforms.dustChaos.value=next.chaos*2;this.uniforms.dustBounce.value=next.bounceLines?1:0;
46
+ const angle=next.recipe.angle*Math.PI/180;
47
+ const snap=v=>Math.abs(v)<1e-12?0:v;
48
+ this.uniforms.dustRecipeDirection.value.set(snap(Math.cos(angle)),snap(Math.sin(angle)));
49
+ for(const name of ['Depth','Spin','Sway','Shape','Glow','Rebound','Pace'])this.uniforms['dustRecipe'+name].value=next.recipe[name.toLowerCase()];
50
+ this.uniforms.dustRecipePalette.value.fromArray({original:[-1,-1,-1],cool:[.3,.7,1],warm:[1,.5,.2],mint:[.3,1,.7]}[next.recipe.palette]);
51
+ this.characterView=null;
52
+ for(const material of this.materials)this.syncMaterialDefines(material);
53
+ }
54
+ syncMaterialDefines(material){
55
+ const scale=this.uniforms.dustTransform.value;
56
+ const flags={THD_EDIT_MODE:textEditMotions[this.mode].id,THD_EDIT_CHARACTER_FRAME:textEditMotions[this.mode].characterFrame||material.userData.triangleMotionFrame?1:0,THD_EDIT_ADJUST:scale.x!==1||scale.y!==1||scale.z!==1?1:0,THD_EDIT_BOUNCE:this.settings.bounceLines?1:0};
57
+ if(Object.entries(flags).some(([key,value])=>material.defines?.[key]!==value)){material.defines={...material.defines,...flags};material.needsUpdate=true;}
58
+ }
59
+ randomSeed(){return this.settings.seed===null?Math.random():this.settings.seed/4294967296;}
60
+ allocate(count){
61
+ if(this.capacity>=count)return;
62
+ this.capacity=2**Math.ceil(Math.log2(Math.max(1,count)));
63
+ this.uniforms.dustData.value?.dispose();
64
+ const THREE=this.THREE,texture=new THREE.DataTexture(new Float32Array(this.capacity*8),this.capacity,2,THREE.RGBAFormat,THREE.FloatType);
65
+ texture.minFilter=texture.magFilter=THREE.NearestFilter;texture.generateMipmaps=false;
66
+ this.uniforms.dustData.value=texture;this.uniforms.dustWidth.value=this.capacity;
67
+ }
68
+ decorate(view){
69
+ const THREE=this.THREE;
70
+ this.materials=new Set(Array.from(view.sharedGroups?.values()||[],({mesh})=>mesh.material));
71
+ for(const {mesh} of view.sharedGroups?.values()||[]){
72
+ const geometry=mesh.geometry;
73
+ if(this.settings.bounceLines&&!geometry.getAttribute('dustCornerA')){
74
+ const a=geometry.getAttribute('position'),corners=[new Float32Array(a.count*3),new Float32Array(a.count*3),new Float32Array(a.count*3)];
75
+ for(let i=0;i<a.count;i+=3)for(let c=0;c<3;c++)for(let j=0;j<3;j++)corners[c].set(a.array.subarray((i+c)*3,(i+c+1)*3),(i+j)*3);
76
+ corners.forEach((values,i)=>geometry.setAttribute(['dustCornerA','dustCornerB','dustCornerC'][i],new THREE.BufferAttribute(values,3)));
77
+ }
78
+ if(textEditMotions[this.mode].contour)ensureTextMotionContour(THREE,geometry);
79
+ particleCenters(THREE,geometry,this.settings.particleShape);
80
+ const material=mesh.material;
81
+ // A surface supplies one local frame; text continues to supply measured
82
+ // per-character attributes. Constant attributes avoid repeating image
83
+ // bounds for every vertex and preserve the same path/bounce evaluator.
84
+ const frame=view.motionFrame;
85
+ material.userData.triangleMotionFrame=!!frame;
86
+ if(frame){
87
+ material.defaultAttributeValues.dustCharacterFrame=[frame.x,frame.y,frame.width,frame.height];
88
+ material.defaultAttributeValues.dustFloor=[frame.y,0];
89
+ material.defaultAttributeValues.dustCeiling=[frame.y+frame.height,0,0,0];
90
+ const motion=textEditMotions[this.mode];
91
+ ensureTriangleMotionFrame(THREE,geometry,frame,{side:motion.characterCenters,floor:motion.characterFloor});
92
+ }
93
+ this.syncMaterialDefines(material);
94
+ if(material.userData.dustWind)continue;
95
+ material.defaultAttributeValues.dustEdge=[1e6,1e6,1e6];
96
+ material.defaultAttributeValues.dustCharacterSide=[0];
97
+ if(!frame){
98
+ material.defaultAttributeValues.dustCharacterFrame=[0,0,0,0];
99
+ material.defaultAttributeValues.dustFloor=[0,0];
100
+ material.defaultAttributeValues.dustCeiling=[0,0,0,0];
101
+ }
102
+ for(const name of ['dustCornerA','dustCornerB','dustCornerC'])material.defaultAttributeValues[name]=[0,0,0];
103
+ material.userData.dustSource={vertexShader:material.vertexShader,fragmentShader:material.fragmentShader};
104
+ material.userData.dustWind=true;Object.assign(material.uniforms,this.uniforms);
105
+ material.vertexShader=`attribute vec2 dustCenter; attribute vec3 dustEdge; attribute float dustCharacterSide; attribute vec2 dustFloor; uniform float dustEnabled; uniform float dustMotion; uniform float dustMaskOnly; uniform float dustEm; uniform float dustSeconds; uniform int dustCount; uniform float dustWidth; uniform sampler2D dustData; varying float dustOpacity; varying float dustGlow; varying vec3 dustGlowColor; varying vec3 dustContour; varying float dustOutline;\n${textMotionPrimitives}\n`+material.vertexShader;
106
+ // Function declarations follow the attributes/uniforms they use.
107
+ const sourceMarker=material.vertexShader.indexOf('void main');
108
+ material.vertexShader=material.vertexShader.slice(0,sourceMarker)+`attribute vec4 dustCharacterFrame; attribute vec4 dustCeiling; attribute vec3 dustCornerA; attribute vec3 dustCornerB; attribute vec3 dustCornerC; uniform vec3 dustTransform; uniform float dustFormation; uniform float dustChaos; uniform float dustBounce;\n${textRecipeUniformShader}\n${textEffectOptionShader}\n${textEffectPathShader}\n`+material.vertexShader.slice(sourceMarker);
109
+ const marker=material.vertexShader.includes('/* THD_TRIANGLE_MOTION */')?'/* THD_TRIANGLE_MOTION */':'float sampleIndex =';
110
+ material.vertexShader=material.vertexShader.replace(marker,`
111
+ dustOpacity=1.0-dustMaskOnly;
112
+ dustGlow=0.0;
113
+ dustGlowColor=vec3(1.0);
114
+ dustContour=dustEdge;
115
+ dustOutline=0.0;
116
+ vec2 dustTarget=dustCenter+glyphOffset;
117
+ if(dustEnabled>0.5){
118
+ for(int k=0;k<dustCount;k++){
119
+ float u=(float(k)+0.5)/dustWidth;
120
+ vec4 r=texture2D(dustData,vec2(u,0.25));
121
+ if(dustTarget.x>=r.x&&dustTarget.x<=r.z&&dustTarget.y>=r.y&&dustTarget.y<=r.w){
122
+ vec4 clock=texture2D(dustData,vec2(u,0.75));
123
+ vec2 particle=(dustTarget-r.xy)/dustEm;
124
+ float seed=fract(sin(dot(particle,vec2(127.1,311.7))+clock.w*53.0)*43758.5453);
125
+ float drift=fract(sin(dot(particle,vec2(269.5,183.3))+clock.w*91.7)*43758.5453);
126
+ float pace=fract(sin(dot(particle,vec2(419.2,371.9))+clock.w*73.0)*43758.5453);
127
+ vec2 travel=thdParticleTravel(seed,drift,pace,dustSeconds);
128
+ travel.y=thdFormationSeconds(travel.y,dustSeconds,dustFormation);
129
+ float t=thdMotionPhase(clock,travel.y,dustSeconds);
130
+ #ifdef THD_HYBRID_GLOBAL_ESCAPE
131
+ hybridEscaping=1.0-step(0.99999,t);
132
+ #endif
133
+ #ifdef THD_RASTER_TEXTURE
134
+ vRasterQuality=smoothstep(0.9,1.0,t);
135
+ #endif
136
+ if(dustChaos!=1.0){
137
+ seed=clamp(mix(0.5,seed,dustChaos),0.0,1.0);
138
+ drift=clamp(mix(0.5,drift,dustChaos),0.0,1.0);
139
+ pace=clamp(mix(0.5,pace,dustChaos),0.0,1.0);
140
+ }
141
+ float remain=1.0-t;
142
+ dustOpacity=1.0;
143
+ ${textEffectApplicationShader}
144
+ dustOpacity*=thdParticleAppearance(t,fract(seed*17.17+drift*3.13));
145
+ p.z=thdFrontDepth(position.z,p.z);
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ ${marker==='float sampleIndex ='?marker:''}`);
151
+ material.fragmentShader='uniform float dustEm; varying float dustOpacity; varying float dustGlow; varying vec3 dustGlowColor; varying vec3 dustContour; varying float dustOutline;\n'+material.fragmentShader;
152
+ material.fragmentShader=material.fragmentShader.replace('coverage);',`coverage * dustOpacity);
153
+ gl_FragColor.rgb=mix(gl_FragColor.rgb,dustGlowColor,clamp(dustGlow,0.0,1.0));
154
+ if(dustOutline>0.0){
155
+ float edge=min(dustContour.x,min(dustContour.y,dustContour.z));
156
+ float border=1.0-smoothstep(dustEm*0.018,dustEm*0.018+max(fwidth(edge),0.00001),edge);
157
+ gl_FragColor.a*=mix(1.0,border,dustOutline);
158
+ }`);material.needsUpdate=true;
159
+ }
160
+ }
161
+ playRegion(view,rectangle,unit,now,{departing=false,seed=this.randomSeed(),fromAge=1}={}){
162
+ const settings=this.settings;
163
+ this.cancel();this.departing=departing;this.uniforms.dustMaskOnly.value=departing?1:0;
164
+ this.settings=null;this.configure(settings);
165
+ this.decorate(view);this.uniforms.dustEm.value=unit;
166
+ this.jobs=[{id:++this.sequence,seed,started:now,fromAge,rectangles:[rectangle]}];
167
+ this.runs++;return this.step(now);
168
+ }
169
+ step(now,reducedMotion=false){
170
+ this.lastTime=now;
171
+ if(reducedMotion){this.cancel();return false;}
172
+ this.jobs=this.jobs.filter(job=>{
173
+ if(job.started===null||now-job.started<this.duration)return true;
174
+ if(!this.departing)this.settledJobs.push(job);
175
+ return false;
176
+ });
177
+ const rectangles=this.jobs.filter(job=>job.started!==null).flatMap(job=>job.rectangles.map(rect=>({rect,age:Math.max(0,(now-job.started)/this.duration),seed:job.seed||0,fromAge:job.fromAge??1})));
178
+ this.allocate(rectangles.length);
179
+ const data=this.uniforms.dustData.value.image.data;
180
+ rectangles.forEach(({rect:r,age,seed,fromAge},i)=>{
181
+ data.set([r.x,r.y,r.x+r.width,r.y+r.height],i*4);
182
+ data.set([age,r.direction==='rtl'?-1:1,this.departing?1+fromAge:0,seed],(this.capacity+i)*4);
183
+ });
184
+ this.uniforms.dustData.value.needsUpdate=true;this.uniforms.dustCount.value=rectangles.length;
185
+ this.ghosts=this.ghosts.filter(ghost=>{
186
+ // First displayed frame is phase zero, even if shaping delayed that frame.
187
+ for(const job of ghost.effect.jobs)job.started??=now;
188
+ if(ghost.effect.step(now))return true;ghost.release();return false;
189
+ });
190
+ this.active=rectangles.length>0||this.ghosts.length>0;this.uniforms.dustEnabled.value=rectangles.length?1:0;return this.active;
191
+ }
192
+ cancel(){for(const ghost of this.ghosts)ghost.release();this.ghosts=[];this.jobs=[];this.settledJobs=[];this.boundView=null;this.boundVersion=null;this.text=null;this.active=false;this.uniforms.dustEnabled.value=0;this.uniforms.dustCount.value=0;}
193
+ dispose(){this.cancel();this.materials.clear();this.uniforms.dustData.value.dispose();}
194
+ stats(){return {mode:this.mode,exitMode:this.exitMode,duration:this.duration,settings:cloneTextEffectOptions(this.settings),active:this.active,pending:this.pending,runs:this.runs,exits:this.exits,departures:this.ghosts.length,departureModes:this.ghosts.map(g=>g.effect.mode),rectangles:this.uniforms.dustCount.value,batches:this.jobs.map(({id,range,started})=>({id,range,started}))};}
195
+ }