@ev-ry/fx 0.1.0-rc.1

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +13 -0
  3. package/QUICKSTART.fa.md +94 -0
  4. package/README.md +116 -0
  5. package/assets/fonts/Estedad-OFL.txt +93 -0
  6. package/assets/vendor/bidi-LICENSE.txt +22 -0
  7. package/assets/vendor/bidi.min.js +1 -0
  8. package/assets/vendor/three-LICENSE.txt +21 -0
  9. package/assets/vendor/three.min.js +7 -0
  10. package/build-report.json +328 -0
  11. package/docs/GUIDE.md +125 -0
  12. package/docs/RELEASE-NOTES.md +40 -0
  13. package/examples/AnimatedTitle.jsx +21 -0
  14. package/examples/navigation-away.html +1 -0
  15. package/examples/navigation.html +38 -0
  16. package/examples/script.html +2 -0
  17. package/package.json +57 -0
  18. package/src/dom-attachment.d.ts +78 -0
  19. package/src/dom-attachment.js +187 -0
  20. package/src/dom-auto-reveal.d.ts +17 -0
  21. package/src/dom-auto-reveal.js +53 -0
  22. package/src/dom-auto-route.js +25 -0
  23. package/src/dom-free-bootstrap.js +42 -0
  24. package/src/dom-free-loader.d.ts +13 -0
  25. package/src/dom-free-loader.js +12 -0
  26. package/src/dom-free-script.js +12 -0
  27. package/src/dom-free.d.ts +16 -0
  28. package/src/dom-free.js +42 -0
  29. package/src/dom-image-raster.js +22 -0
  30. package/src/dom-image-surface.js +109 -0
  31. package/src/dom-image-swap.js +27 -0
  32. package/src/dom-once.js +34 -0
  33. package/src/dom-raster-cache.js +33 -0
  34. package/src/dom-reveal-boot.js +11 -0
  35. package/src/dom-reveal.js +75 -0
  36. package/src/dom-rich-text.js +119 -0
  37. package/src/dom-surface-font.js +50 -0
  38. package/src/dom-svg-surface.js +43 -0
  39. package/src/dom-text-fingerprint.js +27 -0
  40. package/src/dom-text-runs.js +45 -0
  41. package/src/dom-text-surface.js +254 -0
  42. package/src/font-mesh-engine.js +368 -0
  43. package/src/font-rasterizer.js +65 -0
  44. package/src/hybrid-text-flow.js +45 -0
  45. package/src/image-preparation-queue.js +16 -0
  46. package/src/image-source.js +61 -0
  47. package/src/image-surface.js +162 -0
  48. package/src/insertion-range.js +29 -0
  49. package/src/mesh-generator-core.js +149 -0
  50. package/src/motion-envelope.js +22 -0
  51. package/src/motion.js +52 -0
  52. package/src/native-run-shaping.js +60 -0
  53. package/src/particle-centers.js +50 -0
  54. package/src/raster-texture-material.js +79 -0
  55. package/src/raster-texture-mesh.js +35 -0
  56. package/src/render-owner.js +63 -0
  57. package/src/runtime-font-engine.js +242 -0
  58. package/src/runtime-lifecycle.js +26 -0
  59. package/src/text-direction.js +25 -0
  60. package/src/text-edit-effect.js +101 -0
  61. package/src/text-edit-motions.js +15 -0
  62. package/src/text-effect-options.js +29 -0
  63. package/src/text-effect-path.js +61 -0
  64. package/src/text-mesh-density.js +36 -0
  65. package/src/text-motion-character-centers.js +74 -0
  66. package/src/text-motion-contour.js +22 -0
  67. package/src/text-motion-primitives.js +91 -0
  68. package/src/text-motion-programs.js +1 -0
  69. package/src/text-motion-recipes.js +71 -0
  70. package/src/text-scene.js +272 -0
  71. package/src/triangle-coverage.js +25 -0
  72. package/src/triangle-effect.js +194 -0
  73. package/src/triangle-motion-frame.js +38 -0
  74. package/src/viewport-clip.js +119 -0
  75. package/src/viewport-render-owner.js +318 -0
@@ -0,0 +1,272 @@
1
+ import { MOTION_SAMPLES, RadialMotion } from './motion.js';
2
+ import {attachRasterTexture,applyRasterTextureShader} from './raster-texture-material.js';
3
+
4
+ const vertexShader = `
5
+ attribute vec3 color;
6
+ attribute float delay;
7
+ uniform vec2 angles[${MOTION_SAMPLES}];
8
+ uniform float radius;
9
+ uniform float baseline;
10
+ uniform float effectTime;
11
+ varying vec3 vColor;
12
+ varying vec3 vViewPosition;
13
+ void main() {
14
+ vec3 p = position + vec3(0.0, baseline, 0.0);
15
+ float sampleIndex = clamp(length(p.xy) / max(radius, 0.001), 0.0, 1.0) * ${MOTION_SAMPLES - 1}.0;
16
+ int low = min(${MOTION_SAMPLES - 2}, int(floor(sampleIndex)));
17
+ vec2 angle = mix(angles[low], angles[low + 1], sampleIndex - float(low));
18
+ float ry = p.y * cos(angle.x) - p.z * sin(angle.x);
19
+ float rz = p.y * sin(angle.x) + p.z * cos(angle.x);
20
+ p = vec3(p.x * cos(angle.y) + rz * sin(angle.y), ry, -p.x * sin(angle.y) + rz * cos(angle.y));
21
+ float progress = clamp((effectTime - delay) / 0.5, 0.0, 1.0);
22
+ vColor = color * (1.0 - pow(1.0 - progress, 3.0));
23
+ vec4 viewPosition = modelViewMatrix * vec4(p, 1.0);
24
+ vViewPosition = viewPosition.xyz;
25
+ gl_Position = projectionMatrix * viewPosition;
26
+ }
27
+ `;
28
+ const fragmentShader = `
29
+ uniform float brightness;
30
+ uniform vec3 tint;
31
+ varying vec3 vColor;
32
+ varying vec3 vViewPosition;
33
+ void main() {
34
+ vec3 faceNormal = normalize(cross(dFdx(vViewPosition), dFdy(vViewPosition)));
35
+ float facing = abs(faceNormal.z);
36
+ float diffuse = mix(0.16, 1.0, pow(facing, 0.72));
37
+ float sheen = 0.16 * pow(facing, 10.0);
38
+ float coverage = clamp(max(max(vColor.r, vColor.g), vColor.b) * brightness, 0.0, 1.0);
39
+ // Coverage belongs in alpha only; multiplying RGB by it as well darkens thin strokes twice.
40
+ gl_FragColor = vec4(tint * min(1.0, diffuse + sheen), coverage);
41
+ // CSS tint and sampled image RGB are linear in Three's working space.
42
+ // Encode RGB once for the render target; coverage alpha stays unchanged.
43
+ #include <colorspace_fragment>
44
+ }
45
+ `;
46
+
47
+ export class TextScene {
48
+ constructor(THREE, engine, scene) {
49
+ this.THREE = THREE;
50
+ this.engine = engine;
51
+ this.scene = scene;
52
+ this.motion = new RadialMotion();
53
+ this.resources = new Map();
54
+ this.lines = [];
55
+ this.glyphs = [];
56
+ this.version = 0;
57
+ this.effectStarted = null;
58
+ this.uniforms = { angles: { value: this.motion.angles }, radius: { value: 1 }, effectTime: { value: 2 }, brightness: { value: 1.25 }, tint: { value: new THREE.Color(1,1,1) } };
59
+ }
60
+
61
+ setText(text) {
62
+ const THREE = this.THREE;
63
+ const entries = this.engine.buildLines(text);
64
+ const retained = new Map();
65
+ const available = new Map();
66
+ for (const line of this.lines) {
67
+ const queue = available.get(line.entry.mesh) || [];
68
+ queue.push(line);
69
+ available.set(line.entry.mesh, queue);
70
+ }
71
+ this.glyphs = [];
72
+ this.triangleCount = 0;
73
+ this.lines = entries.map(entry => {
74
+ let geometry = retained.get(entry.mesh) || this.resources.get(entry.mesh);
75
+ if (!geometry) {
76
+ geometry = new THREE.BufferGeometry();
77
+ geometry.setAttribute('position', new THREE.BufferAttribute(entry.mesh.positions, 3));
78
+ geometry.setAttribute('color', new THREE.BufferAttribute(entry.mesh.colors, 3));
79
+ geometry.setAttribute('delay', new THREE.BufferAttribute(entry.mesh.delays, 1));
80
+ geometry.computeBoundingBox();
81
+ }
82
+ retained.set(entry.mesh, geometry);
83
+ let line = available.get(entry.mesh)?.shift();
84
+ if (!line) {
85
+ const material = new THREE.ShaderMaterial({
86
+ uniforms: { ...this.uniforms, baseline: { value: entry.baseline } },
87
+ vertexShader, fragmentShader, side: THREE.DoubleSide, transparent: true,
88
+ depthWrite: false, extensions: { derivatives: true },
89
+ });
90
+ const mesh = new THREE.Mesh(geometry, material);
91
+ // Positions move in the vertex shader, outside the undeformed bounding box.
92
+ mesh.frustumCulled = false;
93
+ this.scene.add(mesh);
94
+ line = { mesh };
95
+ }
96
+ line.entry = entry;
97
+ line.mesh.material.uniforms.baseline.value = entry.baseline;
98
+ for (const glyph of entry.mesh.glyphRects) {
99
+ this.glyphs.push({ ...glyph, y: glyph.y + entry.baseline, index: glyph.index + entry.offset });
100
+ }
101
+ this.triangleCount += entry.mesh.triangleCount;
102
+ return line;
103
+ });
104
+ for (const queue of available.values()) for (const line of queue) {
105
+ this.scene.remove(line.mesh);
106
+ line.mesh.material.dispose();
107
+ }
108
+ for (const [key, geometry] of this.resources) if (!retained.has(key)) geometry.dispose();
109
+ this.resources = retained;
110
+ this.bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity };
111
+ for (const { mesh, entry } of this.lines) {
112
+ if (!entry.mesh.positions.length) continue;
113
+ const box = mesh.geometry.boundingBox;
114
+ this.bounds.minX = Math.min(this.bounds.minX, box.min.x);
115
+ this.bounds.maxX = Math.max(this.bounds.maxX, box.max.x);
116
+ this.bounds.minY = Math.min(this.bounds.minY, box.min.y + entry.baseline);
117
+ this.bounds.maxY = Math.max(this.bounds.maxY, box.max.y + entry.baseline);
118
+ }
119
+ if (!Number.isFinite(this.bounds.minX)) this.bounds = { minX: -0.5, maxX: 0.5, minY: -0.5, maxY: 0.5 };
120
+ const b = this.bounds;
121
+ this.motion.radius = Math.max(0.001, Math.hypot(Math.max(Math.abs(b.minX), Math.abs(b.maxX)), Math.max(Math.abs(b.minY), Math.abs(b.maxY))));
122
+ this.uniforms.radius.value = this.motion.radius;
123
+ this.effectStarted = null;
124
+ this.uniforms.effectTime.value = 2;
125
+ this.version++;
126
+ }
127
+
128
+ startEffect(now) { this.effectStarted = now; this.uniforms.effectTime.value = 0; }
129
+
130
+ caretMaterial() {
131
+ const material = new this.THREE.ShaderMaterial({
132
+ uniforms: { ...this.uniforms, baseline: { value: 0 }, effectTime: { value: 2 } },
133
+ vertexShader, fragmentShader, side: this.THREE.DoubleSide, transparent: true,
134
+ depthTest: false, depthWrite: false, extensions: { derivatives: true },
135
+ });
136
+ material.defaultAttributeValues.delay = [0];
137
+ return material;
138
+ }
139
+
140
+ caretGeometry(p) {
141
+ // One column of square cells, at the same sampling density as the font.
142
+ const rows = this.engine.divisions || 48;
143
+ const cell = p.height / rows;
144
+ const positions = [], colors = [];
145
+ for (let row = 0; row < rows; row++) {
146
+ for (const [index, [u, v]] of [[0, 0], [1, 0], [1, 1], [0, 0], [1, 1], [0, 1]].entries()) {
147
+ positions.push(p.x + (u - 0.5) * cell, p.bottom + (row + v) * cell, 0.04);
148
+ const coverage = index < 3 ? 1 : 0.7;
149
+ colors.push(coverage, coverage, coverage);
150
+ }
151
+ }
152
+ const geometry = new this.THREE.BufferGeometry();
153
+ geometry.setAttribute('position', new this.THREE.Float32BufferAttribute(positions, 3));
154
+ geometry.setAttribute('color', new this.THREE.Float32BufferAttribute(colors, 3));
155
+ return geometry;
156
+ }
157
+
158
+ overlayMaterial(alpha) {
159
+ const material = new this.THREE.ShaderMaterial({
160
+ uniforms: { ...this.uniforms, baseline: { value: 0 }, effectTime: { value: 2 } },
161
+ vertexShader, fragmentShader: `varying vec3 vColor; void main(){gl_FragColor=vec4(vColor, ${alpha.toFixed(2)});}`,
162
+ side: this.THREE.DoubleSide, transparent: true, depthTest: false, depthWrite: false,
163
+ });
164
+ material.defaultAttributeValues.delay = [0];
165
+ return material;
166
+ }
167
+
168
+ step(dt, now) {
169
+ if (this.motion.step(dt)) this.version++;
170
+ if (this.effectStarted !== null) {
171
+ this.uniforms.effectTime.value = (now - this.effectStarted) / 1000;
172
+ if (this.uniforms.effectTime.value >= 1.5) this.effectStarted = null;
173
+ }
174
+ return this.motion.active || this.effectStarted !== null;
175
+ }
176
+
177
+ dispose() {
178
+ for (const line of this.lines) { this.scene.remove(line.mesh); line.mesh.material.dispose(); }
179
+ for (const geometry of this.resources.values()) geometry.dispose();
180
+ this.resources.clear();
181
+ this.lines = [];
182
+ }
183
+ }
184
+
185
+ // Shared path: one draw per distinct glyph, with one XY offset per occurrence.
186
+ export class SharedTextScene extends TextScene {
187
+ setText(text) {
188
+ const THREE = this.THREE, scale = this.engine.scale;
189
+ const entries = this.engine.layout(text).entries;
190
+ const groups = new Map();
191
+ this.glyphs = []; this.triangleCount = 0;
192
+ for (const entry of entries) for (const item of entry.metrics.items) {
193
+ const { glyph, x, ch, i } = item;
194
+ if (!glyph) continue;
195
+ const baseline = entry.baseline * scale;
196
+ this.glyphs.push({ x: (x-glyph.drawOffsetX)*scale, y: baseline-glyph.height*scale,
197
+ width: glyph.width*scale, height:glyph.height*scale, character:ch,
198
+ index:entry.offset+entry.metrics.utf16Offsets[i] });
199
+ const count = glyph.meshData?.triangleCount ?? glyph.triangles?.length ?? 0;
200
+ if (!count) continue;
201
+ let offsets = groups.get(glyph);
202
+ if (!offsets) groups.set(glyph, offsets=[]);
203
+ offsets.push(x*scale, baseline);
204
+ this.triangleCount += count;
205
+ }
206
+ const previous = this.sharedGroups || new Map(), next = new Map();
207
+ this.bounds = {minX:Infinity,maxX:-Infinity,minY:Infinity,maxY:-Infinity};
208
+ this.bufferBytes = 0;this.textureBytes=0;
209
+ for (const [glyph, offsets] of groups) {
210
+ let resource = previous.get(glyph);
211
+ if (resource && resource.scale !== scale) { this.releaseShared(resource); resource=null; }
212
+ if (!resource) {
213
+ const local = this.engine.buildLine({items:[{glyph,x:0,ch:'',i:0}],utf16Offsets:[0]});
214
+ const geometry = new THREE.InstancedBufferGeometry();
215
+ geometry.setAttribute('position',new THREE.BufferAttribute(local.positions,3));
216
+ geometry.setAttribute('color',new THREE.BufferAttribute(local.colors,3));
217
+ geometry.setAttribute('delay',new THREE.BufferAttribute(local.delays,1));
218
+ geometry.computeBoundingBox();
219
+ const shader = vertexShader.replace('attribute float delay;', 'attribute float delay; attribute vec2 glyphOffset;')
220
+ .replace('position + vec3(0.0, baseline, 0.0)', 'position + vec3(glyphOffset, 0.0)')
221
+ .replace('(effectTime - delay)', '(effectTime - fract(delay + dot(glyphOffset, vec2(0.173, 0.317))))');
222
+ const material = new THREE.ShaderMaterial({uniforms:{...this.uniforms,baseline:{value:0}},
223
+ vertexShader:shader,fragmentShader,side:THREE.DoubleSide,transparent:true,depthWrite:false,extensions:{derivatives:true}});
224
+ let releaseTexture=null;
225
+ if(glyph.rasterSurface){
226
+ geometry.setAttribute('rasterUV',new THREE.BufferAttribute(glyph.meshData.uvs,2));
227
+ applyRasterTextureShader(material,{mask:glyph.rasterSurface.mask!==false});
228
+ releaseTexture=attachRasterTexture(THREE,material,glyph.rasterSurface);
229
+ }
230
+ const mesh = new THREE.Mesh(geometry,material);mesh.frustumCulled=false;this.scene.add(mesh);
231
+ resource={mesh,scale,releaseTexture};
232
+ }
233
+ const geometry=resource.mesh.geometry;
234
+ let attribute=geometry.getAttribute('glyphOffset');
235
+ // Keep the same GPU buffer while capacity suffices; edits upload only offsets.
236
+ if (!attribute || attribute.count < offsets.length/2 ||
237
+ (attribute.array.length > 32 && offsets.length < attribute.array.length / 4)) {
238
+ // Replacing a BufferAttribute does not release its old GPU buffer in Three.
239
+ if (attribute) geometry.dispose();
240
+ const capacity = 2 ** Math.ceil(Math.log2(Math.max(8, offsets.length)));
241
+ attribute=new THREE.InstancedBufferAttribute(new Float32Array(capacity),2);
242
+ geometry.setAttribute('glyphOffset',attribute);
243
+ }
244
+ let changed = geometry.instanceCount !== offsets.length / 2 || attribute.version === 0;
245
+ for (let i=0; i<offsets.length && !changed; i++) changed = attribute.array[i] !== Math.fround(offsets[i]);
246
+ if (changed) {
247
+ attribute.array.set(offsets);
248
+ attribute.updateRange.offset=0; attribute.updateRange.count=offsets.length;
249
+ attribute.needsUpdate=true;
250
+ }
251
+ geometry.instanceCount=offsets.length/2;
252
+ const box=geometry.boundingBox;
253
+ for(let i=0;i<offsets.length;i+=2){
254
+ this.bounds.minX=Math.min(this.bounds.minX,box.min.x+offsets[i]);
255
+ this.bounds.maxX=Math.max(this.bounds.maxX,box.max.x+offsets[i]);
256
+ this.bounds.minY=Math.min(this.bounds.minY,box.min.y+offsets[i+1]);
257
+ this.bounds.maxY=Math.max(this.bounds.maxY,box.max.y+offsets[i+1]);
258
+ }
259
+ for(const attr of Object.values(geometry.attributes))this.bufferBytes+=attr.array.byteLength;
260
+ if(glyph.rasterSurface)this.textureBytes+=glyph.rasterSurface.rgba.byteLength;
261
+ next.set(glyph,resource);
262
+ }
263
+ for(const [glyph,resource] of previous)if(!next.has(glyph))this.releaseShared(resource);
264
+ this.sharedGroups=next;this.drawCalls=next.size;
265
+ if(!Number.isFinite(this.bounds.minX))this.bounds={minX:-.5,maxX:.5,minY:-.5,maxY:.5};
266
+ const b=this.bounds;
267
+ this.motion.radius=Math.max(.001,Math.hypot(Math.max(Math.abs(b.minX),Math.abs(b.maxX)),Math.max(Math.abs(b.minY),Math.abs(b.maxY))));
268
+ this.uniforms.radius.value=this.motion.radius;this.effectStarted=null;this.uniforms.effectTime.value=2;this.version++;
269
+ }
270
+ releaseShared(resource){this.scene.remove(resource.mesh);resource.mesh.geometry.dispose();resource.mesh.material.dispose();resource.releaseTexture?.();}
271
+ dispose(){for(const resource of this.sharedGroups?.values() || [])this.releaseShared(resource);this.sharedGroups?.clear();super.dispose();}
272
+ }
@@ -0,0 +1,25 @@
1
+ // Ten evenly distributed interior barycentric points (four triangular rows).
2
+ // Approximate coverage for runtime text. These samples cannot prove that a
3
+ // triangle is entirely opaque; geometric merging must use exact integration.
4
+ const patterns=[];
5
+ for(const parity of [0,1])for(const side of [0,1]){
6
+ const pair=parity===0
7
+ ? [[[1,0],[0,1],[0,0]],[[1,0],[1,1],[0,1]]]
8
+ : [[[0,0],[1,1],[1,0]],[[0,0],[0,1],[1,1]]];
9
+ const points=[];
10
+ for(let i=0;i<=3;i++)for(let j=0;j<=3-i;j++){
11
+ const weights=[(i+1/3)/4,(j+1/3)/4,(3-i-j+1/3)/4];
12
+ points.push(pair[side].reduce((v,p,k)=>v+p[0]*weights[k],0),pair[side].reduce((v,p,k)=>v+p[1]*weights[k],0));
13
+ }
14
+ patterns.push(points);
15
+ }
16
+ export function integrateTenSamples(rgba,width,height,rows,columns,cw,ch,sums,counts){
17
+ for(let row=0;row<rows;row++)for(let column=0;column<columns;column++)for(let side=0;side<2;side++){
18
+ const points=patterns[((row+column)%2)*2+side];let sum=0;
19
+ for(let i=0;i<20;i+=2){
20
+ const x=Math.min(width-1,Math.floor((column+points[i])*cw)),y=Math.min(height-1,Math.floor((row+points[i+1])*ch));
21
+ sum+=rgba[(y*width+x)*4+3]/255;
22
+ }
23
+ const at=(row*columns+column)*2+side;sums[at]=sum;counts[at]=10;
24
+ }
25
+ }
@@ -0,0 +1,194 @@
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=smoothstep(0.0,0.1,t);
143
+ ${textEffectApplicationShader}
144
+ p.z=thdFrontDepth(position.z,p.z);
145
+ break;
146
+ }
147
+ }
148
+ }
149
+ ${marker==='float sampleIndex ='?marker:''}`);
150
+ material.fragmentShader='uniform float dustEm; varying float dustOpacity; varying float dustGlow; varying vec3 dustGlowColor; varying vec3 dustContour; varying float dustOutline;\n'+material.fragmentShader;
151
+ material.fragmentShader=material.fragmentShader.replace('coverage);',`coverage * dustOpacity);
152
+ gl_FragColor.rgb=mix(gl_FragColor.rgb,dustGlowColor,clamp(dustGlow,0.0,1.0));
153
+ if(dustOutline>0.0){
154
+ float edge=min(dustContour.x,min(dustContour.y,dustContour.z));
155
+ float border=1.0-smoothstep(dustEm*0.018,dustEm*0.018+max(fwidth(edge),0.00001),edge);
156
+ gl_FragColor.a*=mix(1.0,border,dustOutline);
157
+ }`);material.needsUpdate=true;
158
+ }
159
+ }
160
+ playRegion(view,rectangle,unit,now,{departing=false,seed=this.randomSeed(),fromAge=1}={}){
161
+ const settings=this.settings;
162
+ this.cancel();this.departing=departing;this.uniforms.dustMaskOnly.value=departing?1:0;
163
+ this.settings=null;this.configure(settings);
164
+ this.decorate(view);this.uniforms.dustEm.value=unit;
165
+ this.jobs=[{id:++this.sequence,seed,started:now,fromAge,rectangles:[rectangle]}];
166
+ this.runs++;return this.step(now);
167
+ }
168
+ step(now,reducedMotion=false){
169
+ this.lastTime=now;
170
+ if(reducedMotion){this.cancel();return false;}
171
+ this.jobs=this.jobs.filter(job=>{
172
+ if(job.started===null||now-job.started<this.duration)return true;
173
+ if(!this.departing)this.settledJobs.push(job);
174
+ return false;
175
+ });
176
+ 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})));
177
+ this.allocate(rectangles.length);
178
+ const data=this.uniforms.dustData.value.image.data;
179
+ rectangles.forEach(({rect:r,age,seed,fromAge},i)=>{
180
+ data.set([r.x,r.y,r.x+r.width,r.y+r.height],i*4);
181
+ data.set([age,r.direction==='rtl'?-1:1,this.departing?1+fromAge:0,seed],(this.capacity+i)*4);
182
+ });
183
+ this.uniforms.dustData.value.needsUpdate=true;this.uniforms.dustCount.value=rectangles.length;
184
+ this.ghosts=this.ghosts.filter(ghost=>{
185
+ // First displayed frame is phase zero, even if shaping delayed that frame.
186
+ for(const job of ghost.effect.jobs)job.started??=now;
187
+ if(ghost.effect.step(now))return true;ghost.release();return false;
188
+ });
189
+ this.active=rectangles.length>0||this.ghosts.length>0;this.uniforms.dustEnabled.value=rectangles.length?1:0;return this.active;
190
+ }
191
+ 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;}
192
+ dispose(){this.cancel();this.materials.clear();this.uniforms.dustData.value.dispose();}
193
+ 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}))};}
194
+ }
@@ -0,0 +1,38 @@
1
+ // Optional facet metadata for one rectangular surface. Text retains its own
2
+ // measured grapheme layout. Prepare once per geometry/frame, never per tick.
3
+ const prepared=new WeakMap();
4
+ export function ensureTriangleMotionFrame(THREE,geometry,frame,{side=false,floor=false}={}){
5
+ if(!side&&!floor)return;
6
+ const position=geometry.getAttribute('position'),centers=geometry.getAttribute('dustCenter');
7
+ const key=[frame.x,frame.y,frame.width,frame.height].join(':');
8
+ let cache=prepared.get(geometry);
9
+ if(!cache||cache.centers!==centers||cache.position!==position||cache.version!==position.version||cache.key!==key){
10
+ cache={centers,position,version:position.version,key,side:false,floor:false};prepared.set(geometry,cache);
11
+ }
12
+ const needSide=side&&!cache.side,needFloor=floor&&!cache.floor;
13
+ if(!needSide&&!needFloor)return;
14
+ const sides=needSide?new Float32Array(position.count):null;
15
+ const floors=needFloor?new Float32Array(position.count*2):null;
16
+ const ceilings=needFloor?new Float32Array(position.count*4):null;
17
+ for(let i=0;i<position.count;i+=3){
18
+ const x=centers.getX(i),y=centers.getY(i);
19
+ if(sides)sides.fill(Math.max(-1,Math.min(1,2*(x-frame.x)/frame.width-1)),i,i+3);
20
+ if(floors){
21
+ const low=y-Math.min(position.getY(i),position.getY(i+1),position.getY(i+2));
22
+ const high=Math.max(position.getY(i),position.getY(i+1),position.getY(i+2))-y;
23
+ const left=Math.min(position.getX(i),position.getX(i+1),position.getX(i+2))-x;
24
+ const right=Math.max(position.getX(i),position.getX(i+1),position.getX(i+2))-x;
25
+ for(let j=0;j<3;j++){
26
+ floors.set([frame.y,low],(i+j)*2);
27
+ ceilings.set([frame.y+frame.height,left,right,high],(i+j)*4);
28
+ }
29
+ }
30
+ }
31
+ const write=(name,values,size)=>{
32
+ const old=geometry.getAttribute(name);
33
+ if(old?.array.length===values.length){old.array.set(values);old.needsUpdate=true;}
34
+ else geometry.setAttribute(name,new THREE.BufferAttribute(values,size));
35
+ };
36
+ if(sides){write('dustCharacterSide',sides,1);cache.side=true;}
37
+ if(floors){write('dustFloor',floors,2);write('dustCeiling',ceilings,4);cache.floor=true;}
38
+ }
@@ -0,0 +1,119 @@
1
+ // Rounded ancestor clips supplement the owner's rectangular scissor. Coordinates
2
+ // are absolute CSS viewport pixels; radii run clockwise from the top-left corner.
3
+ export const MAX_VIEWPORT_CLIPS=8;
4
+ const clipsOverflow=value=>/^(auto|scroll|hidden|clip)$/.test(value);
5
+ const cornerNames=['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius'];
6
+
7
+ function radiusLength(value,size){
8
+ const match=/^(\d*\.?\d+)(px|%)?$/.exec(value);
9
+ if(!match||!match[2]&&Number(match[1])!==0)throw TypeError('Viewport clipping requires resolved px or percentage border radii');
10
+ return Number(match[1])*(match[2]==='%'?size/100:1);
11
+ }
12
+
13
+ export function readViewportClip(node,style,box){
14
+ // A visible axis does not acquire rounded clipping from the other axis.
15
+ if(!clipsOverflow(style.overflowX)||!clipsOverflow(style.overflowY))return null;
16
+ const width=node.offsetWidth,height=node.offsetHeight;
17
+ if(!(width>0&&height>0&&box.width>0&&box.height>0&&node.clientWidth>0&&node.clientHeight>0))return null;
18
+ const radii=cornerNames.map(name=>{
19
+ const pair=(style[name]||'0px').trim().split(/\s+/);
20
+ if(pair.length>2)throw TypeError('Viewport clipping requires resolved px or percentage border radii');
21
+ return [radiusLength(pair[0],width),radiusLength(pair[1]||pair[0],height)];
22
+ });
23
+ // CSS reduces every outer radius by one common factor before computing the
24
+ // inner border curve. Do not renormalize inner radii: a thick opposite border
25
+ // can legitimately truncate an arc to less than a quarter ellipse.
26
+ const factor=Math.min(1,
27
+ width/(radii[0][0]+radii[1][0]||1),width/(radii[3][0]+radii[2][0]||1),
28
+ height/(radii[0][1]+radii[3][1]||1),height/(radii[1][1]+radii[2][1]||1));
29
+ const left=node.clientLeft,top=node.clientTop,right=Math.max(0,width-left-node.clientWidth),bottom=Math.max(0,height-top-node.clientHeight);
30
+ const sx=box.width/width,sy=box.height/height;
31
+ const insets=[[left,top],[right,top],[right,bottom],[left,bottom]];
32
+ for(let i=0;i<4;i++){
33
+ radii[i][0]=Math.max(0,radii[i][0]*factor-insets[i][0])*sx;
34
+ radii[i][1]=Math.max(0,radii[i][1]*factor-insets[i][1])*sy;
35
+ }
36
+ if(!radii.some(([x,y])=>x>0&&y>0))return null;
37
+ // Client dimensions include padding; subtracting content padding here would
38
+ // incorrectly hide overflow that CSS allows to paint inside that padding.
39
+ return {left:box.left+left*sx,top:box.top+top*sy,right:box.left+(left+node.clientWidth)*sx,bottom:box.top+(top+node.clientHeight)*sy,radii};
40
+ }
41
+
42
+ const marker='/* THD_VIEWPORT_ROUNDED_CLIP */';
43
+ const clipMaterials=new WeakSet();
44
+ const clipShader=`${marker}
45
+ uniform int thdViewportClipCount;
46
+ uniform vec4 thdViewportClipRects[${MAX_VIEWPORT_CLIPS}];
47
+ uniform vec4 thdViewportClipRadiiX[${MAX_VIEWPORT_CLIPS}];
48
+ uniform vec4 thdViewportClipRadiiY[${MAX_VIEWPORT_CLIPS}];
49
+ uniform vec3 thdViewportClipOrigin;
50
+ float thdViewportCornerAlpha(vec2 delta,vec2 radius){
51
+ if(radius.x<=0.0||radius.y<=0.0||delta.x<=0.0||delta.y<=0.0)return 1.0;
52
+ vec2 q=delta/radius;
53
+ float edge=dot(q,q)-1.0;
54
+ // First-order ellipse distance supplies one physical pixel of antialiasing,
55
+ // without requiring derivative extensions or changing the surface's RGB.
56
+ float distance=edge/max(2.0*length(q/radius),0.000001);
57
+ return clamp(0.5-distance/thdViewportClipOrigin.z,0.0,1.0);
58
+ }
59
+ float thdViewportClipAlpha(){
60
+ vec2 p=vec2(thdViewportClipOrigin.x+gl_FragCoord.x*thdViewportClipOrigin.z,
61
+ thdViewportClipOrigin.y-gl_FragCoord.y*thdViewportClipOrigin.z);
62
+ float alpha=1.0;
63
+ for(int i=0;i<${MAX_VIEWPORT_CLIPS};i++){
64
+ if(i>=thdViewportClipCount)break;
65
+ vec4 b=thdViewportClipRects[i],rx=thdViewportClipRadiiX[i],ry=thdViewportClipRadiiY[i];
66
+ alpha=min(alpha,thdViewportCornerAlpha(vec2(b.x+rx.x-p.x,b.y+ry.x-p.y),vec2(rx.x,ry.x)));
67
+ alpha=min(alpha,thdViewportCornerAlpha(vec2(p.x-b.z+rx.y,b.y+ry.y-p.y),vec2(rx.y,ry.y)));
68
+ alpha=min(alpha,thdViewportCornerAlpha(vec2(p.x-b.z+rx.z,p.y-b.w+ry.z),vec2(rx.z,ry.z)));
69
+ alpha=min(alpha,thdViewportCornerAlpha(vec2(b.x+rx.w-p.x,p.y-b.w+ry.w),vec2(rx.w,ry.w)));
70
+ }
71
+ return alpha;
72
+ }
73
+ `;
74
+
75
+ function decorateMaterial(material){
76
+ if(material.fragmentShader.includes(marker))return;
77
+ const source=material.fragmentShader;
78
+ // Preserve functions following main as well as existing opacity/effect hooks.
79
+ const clean=source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,match=>' '.repeat(match.length));
80
+ const main=/\bvoid\s+main\s*\(\s*(?:void\s*)?\)\s*\{/.exec(clean);
81
+ if(!main)throw TypeError('Viewport clipping requires a ShaderMaterial main function');
82
+ let end=main.index+main[0].length,depth=1;
83
+ for(;end<clean.length&&depth;end++){if(clean[end]==='{')depth++;else if(clean[end]==='}')depth--;}
84
+ if(depth)throw TypeError('Viewport clipping requires a complete ShaderMaterial main function');
85
+ const finish='\nfloat thdClipCoverage=thdViewportClipAlpha();\nif(thdClipCoverage<=0.0)discard;\ngl_FragColor.a *= thdClipCoverage;\n';
86
+ material.fragmentShader=clipShader+source.slice(0,end-1)+finish+source.slice(end-1);
87
+ material.needsUpdate=true;
88
+ }
89
+
90
+ export function applyViewportClips(scene,clips,viewport,dpr){
91
+ if(clips.length>MAX_VIEWPORT_CLIPS)throw RangeError(`At most ${MAX_VIEWPORT_CLIPS} rounded viewport clips are supported`);
92
+ if(!(Number.isFinite(dpr)&&dpr>0))throw RangeError('A positive viewport pixel ratio is required');
93
+ scene.traverse(object=>{
94
+ for(const material of Array.isArray(object.material)?object.material:[object.material]){
95
+ if(!material?.isShaderMaterial)continue;
96
+ if(!clips.length&&!material.fragmentShader.includes(marker))continue;
97
+ decorateMaterial(material);
98
+ const uniforms=material.uniforms;
99
+ // Three's uniform clone can retain typed-array references. Give each new
100
+ // material its own clip buffers so cloned scenes cannot move a peer's clip.
101
+ if(!clipMaterials.has(material)){
102
+ uniforms.thdViewportClipCount={value:0};
103
+ uniforms.thdViewportClipOrigin={value:new Float32Array(3)};
104
+ for(const name of ['thdViewportClipRects','thdViewportClipRadiiX','thdViewportClipRadiiY'])uniforms[name]={value:new Float32Array(MAX_VIEWPORT_CLIPS*4)};
105
+ clipMaterials.add(material);
106
+ }
107
+ uniforms.thdViewportClipCount.value=clips.length;
108
+ uniforms.thdViewportClipOrigin.value.set([viewport.left,viewport.bottom,1/dpr]);
109
+ for(let i=0;i<clips.length;i++){
110
+ const clip=clips[i],offset=i*4;
111
+ uniforms.thdViewportClipRects.value.set([clip.left,clip.top,clip.right,clip.bottom],offset);
112
+ for(let corner=0;corner<4;corner++){
113
+ uniforms.thdViewportClipRadiiX.value[offset+corner]=clip.radii[corner][0];
114
+ uniforms.thdViewportClipRadiiY.value[offset+corner]=clip.radii[corner][1];
115
+ }
116
+ }
117
+ }
118
+ });
119
+ }