@ev-ry/fx 0.1.0-rc.2 → 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,160 +1,164 @@
1
- import {imageMotionPixels} from './motion-envelope.js';
2
- import {loadImageRaster} from './image-source.js';
3
- import {rasterToTextureMesh} from './raster-texture-mesh.js';
4
- import {attachRasterTexture,applyRasterSamplingShader} from './raster-texture-material.js';
5
- import {textDivisionsForSize,createProjectedTextSize} from './text-mesh-density.js';
6
- import {TriangleEffect} from './triangle-effect.js';
7
- import {normalizeTextEffectOptions} from './text-effect-options.js';
8
- import {textEditMotions} from './text-edit-motions.js';
9
-
10
- export const IMAGE_EFFECT_MODES=Object.freeze(Object.keys(textEditMotions));
11
- // Images share motion recipes with text, but preserve source color by default.
12
- export function normalizeImageEffectOptions(settings={},mode='dust-wind'){
13
- const normalized=normalizeTextEffectOptions({formation:-1,...settings},mode);
14
- return Object.freeze({...normalized,recipe:Object.freeze({...normalized.recipe,glow:settings.recipe?.glow??0})});
15
- }
16
-
17
- // Images use twice the text-derived rows: half-size cells, with a bounded grid.
18
- export function imageDivisions(width,height,displayHeight,current=null,maxTriangles=80000){
19
- let rows=2*textDivisionsForSize(displayHeight,current===null?null:current/2);
20
- while(rows>1&&2*rows*Math.max(1,Math.round(width*rows/height))>maxTriangles)rows--;
21
- if(2*rows*Math.max(1,Math.round(width*rows/height))>maxTriangles)throw Error('Image aspect ratio exceeds the triangle budget');
22
- return rows;
23
- }
24
-
25
- const vertexShader=`
26
- attribute vec2 rasterUV;
27
- attribute vec2 glyphOffset;
28
- varying vec2 vRasterUV;
29
- void main(){
30
- vRasterUV=rasterUV;
31
- vec3 p=position+vec3(glyphOffset,0.0);
32
- /* THD_TRIANGLE_MOTION */
33
- gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);
34
- }`;
35
- const fragmentShader=`
36
- uniform sampler2D rasterMap;
37
- varying vec2 vRasterUV;
38
- void main(){
39
- vec4 rasterSample=texture2D(rasterMap,vRasterUV);
40
- float coverage=rasterSample.a;
41
- gl_FragColor=vec4(rasterSample.rgb, coverage);
42
- #include <colorspace_fragment>
43
- }`;
44
-
45
- // One image object in an existing runtime. No renderer, input or font ownership.
46
- export function createImageSurface(runtime,{source,width=6,height=3.8,effect='dust-wind',settings={},direction='ltr'}={}){
47
- if(runtime.disposed)throw Error('Spatial runtime disposed');
48
- if(!Number.isFinite(width)||!Number.isFinite(height)||width<=0||height<=0)throw RangeError('Positive image bounds required');
49
- const validMode=mode=>{if(!IMAGE_EFFECT_MODES.includes(mode))throw TypeError('Unsupported image effect');};
50
- const validDirection=value=>{if(!['ltr','rtl'].includes(value))throw TypeError('Invalid direction');};
51
- validMode(effect);validDirection(direction);
52
- const {THREE}=runtime,document=runtime.renderer.domElement.ownerDocument,object=new THREE.Group();
53
- const motion=new TriangleEffect(THREE,{mode:effect,settings:normalizeImageEffectOptions(settings,effect)}),measure=createProjectedTextSize(THREE);
54
- let requestedSettings=settings,entryMode=effect,entrySettings=motion.settings;
55
- let disposed=false,asset=null,controller=null,revision=0,loading=false,error=null,state='empty',intent=null;
56
- let displaySize=null;
57
- let lastSeed=null,builds=0,loadMs=0,buildMs=0;
58
- const view={sharedGroups:new Map(),version:0};
59
- function releaseAsset(){
60
- motion.cancel();motion.uniforms.dustMaskOnly.value=0;motion.materials.clear();view.sharedGroups.clear();
61
- if(asset){object.remove(asset.mesh);asset.mesh.geometry.dispose();asset.mesh.material.dispose();asset.releaseTexture();asset=null;}
62
- }
63
- function geometry(raster,rows,w,h,filterRadius,aspect){
64
- const mesh=rasterToTextureMesh(raster.rgba,raster.width,raster.height,rows,{filterRadius,aspect}),positions=new Float32Array(mesh.triangleCount*9);
65
- for(let i=0;i<mesh.coordinates.length;i+=2){const at=i/2*3;positions[at]=(mesh.coordinates[i]/raster.width-.5)*w;positions[at+1]=(.5-mesh.coordinates[i+1]/raster.height)*h;}
66
- const result=new THREE.BufferGeometry();result.setAttribute('position',new THREE.BufferAttribute(positions,3));
67
- result.setAttribute('rasterUV',new THREE.BufferAttribute(mesh.uvs,2));result.computeBoundingBox();
68
- return result;
69
- }
70
- function meshSettings(raster,h,current=null){
71
- const displayHeight=displaySize?.height??measure(object,runtime.camera,runtime.renderer.domElement,h);
72
- const aspect=displaySize?displaySize.width/displaySize.height:raster.width/raster.height;
73
- // Mipmap/bilinear filtering sees beyond the original opaque texel. Retain
74
- // that support too, especially when a wide image is displayed very small.
75
- const ratio=raster.height/Math.max(1,displayHeight*(runtime.renderer.getPixelRatio?.()||1));
76
- const filterRadius=displayHeight>0?Math.max(.5,2**Math.ceil(Math.log2(Math.max(1,ratio)))):.5;
77
- return {rows:imageDivisions(aspect,1,displayHeight,current),filterRadius,aspect};
78
- }
79
- function resizeMesh(){
80
- if(!asset||motion.active)return;
81
- const next=meshSettings(asset.raster,asset.h,asset.rows);if(next.rows===asset.rows&&next.filterRadius===asset.filterRadius&&next.aspect===asset.aspect)return;
82
- const start=performance.now(),replacement=geometry(asset.raster,next.rows,asset.w,asset.h,next.filterRadius,next.aspect);
83
- asset.mesh.geometry.dispose();asset.mesh.geometry=replacement;asset.rows=next.rows;asset.aspect=next.aspect;asset.filterRadius=next.filterRadius;view.version++;builds++;buildMs=performance.now()-start;
84
- motion.decorate(view);
85
- }
86
- async function setSource(value,{preserveMotion=false,bounds=null}={}){
87
- if(disposed)throw Error('Image surface disposed');
88
- const token=++revision;controller?.abort();controller=new AbortController();loading=true;error=null;
89
- try{
90
- const raster=await loadImageRaster(value,{document,signal:controller.signal,maxSide:Math.min(2048,runtime.renderer.capabilities?.maxTextureSize||2048)});
91
- if(disposed||token!==revision)return false;
92
- if(bounds){width=bounds.width;height=bounds.height;}
93
- const start=performance.now(),factor=Math.min(width/raster.width,height/raster.height),w=raster.width*factor,h=raster.height*factor;
94
- const {rows,filterRadius,aspect}=meshSettings(raster,h),g=geometry(raster,rows,w,h,filterRadius,aspect);
95
- const material=new THREE.ShaderMaterial({vertexShader,fragmentShader,side:THREE.DoubleSide,transparent:true,depthWrite:false,toneMapped:false,extensions:{derivatives:true}});
96
- applyRasterSamplingShader(material);
97
- material.defaultAttributeValues.glyphOffset=[0,0];
98
- const releaseTexture=attachRasterTexture(THREE,material,raster),mesh=new THREE.Mesh(g,material);mesh.frustumCulled=false;
99
- // Replacement of DOM pixels/size must not restart an existing effect or
100
- // reveal an image that has already completed its exit.
101
- const previousState=state,job=motion.jobs[0],seed=lastSeed;
102
- const previousIntent=preserveMotion?(intent??(motion.active&&job?{departing:motion.departing,fromAge:job.fromAge,started:job.started,seed}:null)):null;
103
- releaseAsset();asset={raster,w,h,rows,filterRadius,aspect,mesh,releaseTexture};object.add(mesh);view.sharedGroups.set('image',{mesh});view.version++;
104
- view.motionFrame={x:-w/2,y:-h/2,width:w,height:h};
105
- state=preserveMotion?previousState:'visible';intent=previousIntent;lastSeed=preserveMotion?seed:null;mesh.visible=state!=='hidden';builds++;loadMs=raster.decodeMs;buildMs=performance.now()-start;runtime.requestRender();return true;
106
- }catch(e){if(disposed||token!==revision||e.name==='AbortError')return false;error=e.message;throw e;}
107
- finally{if(token===revision){loading=false;runtime.requestRender();}}
108
- }
109
- function play(departing){
110
- if(disposed||!asset)return false;
111
- if(departing&&(state==='hidden'||state==='leaving'||intent?.departing))return false;
112
- const fromAge=state==='entering'&&motion.jobs[0]?Math.max(0,Math.min(1,((motion.lastTime??motion.jobs[0].started)-motion.jobs[0].started)/motion.duration)):1;
113
- intent={departing,fromAge};runtime.requestRender();return true;
114
- }
115
- const control={object,
116
- frame(now,reducedMotion){
117
- if(disposed||!asset)return;
118
- resizeMesh();
119
- if(intent){
120
- const {departing,fromAge,started,seed}=intent;intent=null;asset.mesh.visible=true;state=departing?'leaving':'entering';
121
- const exitMode=entrySettings.exitEffect==='same'?entryMode:entrySettings.exitEffect;
122
- const activeMode=departing?exitMode:entryMode;
123
- motion.setMode(activeMode);motion.configure(normalizeImageEffectOptions(requestedSettings,activeMode));
124
- if(seed!==undefined)lastSeed=seed;else if(!departing||lastSeed===null)lastSeed=motion.randomSeed();
125
- // DOM image coordinates are source pixels, unlike the spatial runtime's
126
- // world units. Keep travel at a stable 32 CSS px for DOM presentations.
127
- const shownHeight=displaySize?.height??measure(object,runtime.camera,runtime.renderer.domElement,asset.h);
128
- const shownWidth=displaySize?.width??shownHeight*asset.w/asset.h;
129
- const motionUnit=shownHeight>0?imageMotionPixels(shownWidth,shownHeight)*asset.h/shownHeight:.65;
130
- motion.playRegion(view,{x:-asset.w/2,y:-asset.h/2,width:asset.w,height:asset.h,direction},motionUnit,started??now,{departing,seed:lastSeed,fromAge:departing&&exitMode!==entryMode?1:fromAge});
131
- }
132
- if(motion.active){
133
- motion.step(now,reducedMotion);
134
- // This surface reuses one material for both directions. Refresh its
135
- // dynamic clock/mask uniforms on the first draw after a transition too.
136
- asset.mesh.material.uniformsNeedUpdate=true;
137
- // Upload this frame's region data before reusing the same sampler.
138
- runtime.renderer.initTexture?.(motion.uniforms.dustData.value);
139
- if(motion.active)runtime.requestRender();
140
- else{state=motion.departing?'hidden':'visible';asset.mesh.visible=!motion.departing;}
141
- }
142
- resizeMesh();
143
- },
144
- invalidate(){runtime.requestRender();},
145
- destroy(){if(disposed)return;disposed=true;revision++;controller?.abort();unregister();releaseAsset();motion.dispose();state='disposed';},
146
- };
147
- const unregister=runtime.register(control);
148
- return {object,ready:source===undefined?Promise.resolve(false):setSource(source),setSource,
149
- enter:()=>play(false),exit:()=>play(true),
150
- setEffect(mode,options={}){validMode(mode);const next=normalizeImageEffectOptions(options,mode);if(disposed)return;requestedSettings=options;entryMode=mode;entrySettings=next;motion.setMode(mode);motion.configure(next);motion.cancel();motion.uniforms.dustMaskOnly.value=0;intent=null;if(asset){asset.mesh.visible=true;state='visible';}runtime.requestRender();},
151
- setDisplaySize(width,height){if(!(Number.isFinite(width)&&Number.isFinite(height)&&width>0&&height>0))throw RangeError('Positive display dimensions required');displaySize={width,height};},
152
- setDirection(value){validDirection(value);direction=value;},
153
- show(){if(disposed||!asset)return;intent=null;motion.cancel();motion.uniforms.dustMaskOnly.value=0;asset.mesh.visible=true;state='visible';runtime.requestRender();},
154
- frame:control.frame,destroy:control.destroy,
155
- stats(){const raster=asset?.raster,g=asset?.mesh.geometry;return {disposed,loading,error,state,active:motion.active,mode:entryMode,activeMode:motion.mode,exitMode:entrySettings.exitEffect==='same'?entryMode:entrySettings.exitEffect,duration:motion.duration,settings:{...motion.settings,recipe:{...motion.settings.recipe}},direction,motionFrame:asset?{...view.motionFrame}:null,divisions:asset?.rows,gridAspect:asset?.aspect,gridColumns:asset?Math.max(1,Math.round(asset.rows*asset.aspect)):0,triangles:g?g.getAttribute('position').count/3:0,builds,loadMs,buildMs,rasterBytes:raster?.rgba.byteLength||0,estimatedTextureBytes:raster?Math.ceil(raster.rgba.byteLength*4/3):0,geometryBytes:g?Object.values(g.attributes).reduce((n,a)=>n+a.array.byteLength,0):0,source:raster?{type:raster.type,width:raster.width,height:raster.height,originalWidth:raster.originalWidth,originalHeight:raster.originalHeight}:null};},
156
- };
157
- }
1
+ import {imageMotionPixels} from './motion-envelope.js';
2
+ import {loadImageRaster} from './image-source.js';
3
+ import {rasterToTextureMesh} from './raster-texture-mesh.js';
4
+ import {attachRasterTexture,applyRasterSamplingShader} from './raster-texture-material.js';
5
+ import {textDivisionsForSize,createProjectedTextSize} from './text-mesh-density.js';
6
+ import {TriangleEffect} from './triangle-effect.js';
7
+ import {normalizeTextEffectOptions} from './text-effect-options.js';
8
+ import {textEditMotions} from './text-edit-motions.js';
9
+
10
+ export const IMAGE_EFFECT_MODES=Object.freeze(Object.keys(textEditMotions));
11
+ // Images share motion recipes with text, but preserve source color by default.
12
+ export function normalizeImageEffectOptions(settings={},mode='dust-wind'){
13
+ const normalized=normalizeTextEffectOptions({formation:-1,...settings},mode);
14
+ return Object.freeze({...normalized,recipe:Object.freeze({...normalized.recipe,glow:settings.recipe?.glow??0})});
15
+ }
16
+
17
+ // Images use twice the text-derived rows: half-size cells, with a bounded grid.
18
+ export function imageDivisions(width,height,displayHeight,current=null,maxTriangles=80000){
19
+ let rows=2*textDivisionsForSize(displayHeight,current===null?null:current/2);
20
+ while(rows>1&&2*rows*Math.max(1,Math.round(width*rows/height))>maxTriangles)rows--;
21
+ if(2*rows*Math.max(1,Math.round(width*rows/height))>maxTriangles)throw Error('Image aspect ratio exceeds the triangle budget');
22
+ return rows;
23
+ }
24
+
25
+ const vertexShader=`
26
+ attribute vec2 rasterUV;
27
+ attribute vec2 glyphOffset;
28
+ varying vec2 vRasterUV;
29
+ void main(){
30
+ vRasterUV=rasterUV;
31
+ vec3 p=position+vec3(glyphOffset,0.0);
32
+ /* THD_TRIANGLE_MOTION */
33
+ gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);
34
+ }`;
35
+ const fragmentShader=`
36
+ uniform float presentationOpacity;
37
+ uniform sampler2D rasterMap;
38
+ varying vec2 vRasterUV;
39
+ void main(){
40
+ vec4 rasterSample=texture2D(rasterMap,vRasterUV);
41
+ float coverage=rasterSample.a;
42
+ gl_FragColor=vec4(rasterSample.rgb, coverage);
43
+ #include <colorspace_fragment>
44
+ gl_FragColor.a *= presentationOpacity;
45
+ }`;
46
+
47
+ // One image object in an existing runtime. No renderer, input or font ownership.
48
+ export function createImageSurface(runtime,{source,width=6,height=3.8,effect='dust-wind',settings={},direction='ltr'}={}){
49
+ if(runtime.disposed)throw Error('Spatial runtime disposed');
50
+ if(!Number.isFinite(width)||!Number.isFinite(height)||width<=0||height<=0)throw RangeError('Positive image bounds required');
51
+ const validMode=mode=>{if(!IMAGE_EFFECT_MODES.includes(mode))throw TypeError('Unsupported image effect');};
52
+ const validDirection=value=>{if(!['ltr','rtl'].includes(value))throw TypeError('Invalid direction');};
53
+ validMode(effect);validDirection(direction);
54
+ const {THREE}=runtime,document=runtime.renderer.domElement.ownerDocument,object=new THREE.Group();
55
+ const motion=new TriangleEffect(THREE,{mode:effect,settings:normalizeImageEffectOptions(settings,effect)}),measure=createProjectedTextSize(THREE);
56
+ let requestedSettings=settings,entryMode=effect,entrySettings=motion.settings;
57
+ let disposed=false,asset=null,controller=null,revision=0,loading=false,error=null,state='empty',intent=null;
58
+ let displaySize=null;
59
+ let lastSeed=null,builds=0,loadMs=0,buildMs=0;
60
+ const view={sharedGroups:new Map(),version:0};
61
+ function releaseAsset(){
62
+ motion.cancel();motion.uniforms.dustMaskOnly.value=0;motion.materials.clear();view.sharedGroups.clear();
63
+ if(asset){object.remove(asset.mesh);asset.mesh.geometry.dispose();asset.mesh.material.dispose();asset.releaseTexture();asset=null;}
64
+ }
65
+ function geometry(raster,rows,w,h,filterRadius,aspect){
66
+ const mesh=rasterToTextureMesh(raster.rgba,raster.width,raster.height,rows,{filterRadius,aspect}),positions=new Float32Array(mesh.triangleCount*9);
67
+ for(let i=0;i<mesh.coordinates.length;i+=2){const at=i/2*3;positions[at]=(mesh.coordinates[i]/raster.width-.5)*w;positions[at+1]=(.5-mesh.coordinates[i+1]/raster.height)*h;}
68
+ const result=new THREE.BufferGeometry();result.setAttribute('position',new THREE.BufferAttribute(positions,3));
69
+ result.setAttribute('rasterUV',new THREE.BufferAttribute(mesh.uvs,2));result.computeBoundingBox();
70
+ return result;
71
+ }
72
+ function meshSettings(raster,h,current=null){
73
+ const displayHeight=displaySize?.height??measure(object,runtime.camera,runtime.renderer.domElement,h);
74
+ const aspect=displaySize?displaySize.width/displaySize.height:raster.width/raster.height;
75
+ // Mipmap/bilinear filtering sees beyond the original opaque texel. Retain
76
+ // that support too, especially when a wide image is displayed very small.
77
+ const ratio=raster.height/Math.max(1,displayHeight*(runtime.renderer.getPixelRatio?.()||1));
78
+ const filterRadius=displayHeight>0?Math.max(.5,2**Math.ceil(Math.log2(Math.max(1,ratio)))):.5;
79
+ return {rows:imageDivisions(aspect,1,displayHeight,current),filterRadius,aspect};
80
+ }
81
+ function resizeMesh(){
82
+ if(!asset||motion.active)return;
83
+ const next=meshSettings(asset.raster,asset.h,asset.rows);if(next.rows===asset.rows&&next.filterRadius===asset.filterRadius&&next.aspect===asset.aspect)return;
84
+ const start=performance.now(),replacement=geometry(asset.raster,next.rows,asset.w,asset.h,next.filterRadius,next.aspect);
85
+ asset.mesh.geometry.dispose();asset.mesh.geometry=replacement;asset.rows=next.rows;asset.aspect=next.aspect;asset.filterRadius=next.filterRadius;view.version++;builds++;buildMs=performance.now()-start;
86
+ motion.decorate(view);
87
+ }
88
+ async function setSource(value,{preserveMotion=false,bounds=null}={}){
89
+ if(disposed)throw Error('Image surface disposed');
90
+ const token=++revision;controller?.abort();controller=new AbortController();loading=true;error=null;
91
+ try{
92
+ const raster=await loadImageRaster(value,{document,signal:controller.signal,maxSide:Math.min(2048,runtime.renderer.capabilities?.maxTextureSize||2048)});
93
+ if(disposed||token!==revision)return false;
94
+ if(bounds){width=bounds.width;height=bounds.height;}
95
+ const start=performance.now(),factor=Math.min(width/raster.width,height/raster.height),w=raster.width*factor,h=raster.height*factor;
96
+ const {rows,filterRadius,aspect}=meshSettings(raster,h),g=geometry(raster,rows,w,h,filterRadius,aspect);
97
+ const material=new THREE.ShaderMaterial({vertexShader,fragmentShader,side:THREE.DoubleSide,transparent:true,depthWrite:false,toneMapped:false,extensions:{derivatives:true}});
98
+ material.uniforms.presentationOpacity={value:1};
99
+ applyRasterSamplingShader(material);
100
+ material.defaultAttributeValues.glyphOffset=[0,0];
101
+ const releaseTexture=attachRasterTexture(THREE,material,raster),mesh=new THREE.Mesh(g,material);mesh.frustumCulled=false;
102
+ // Replacement of DOM pixels/size must not restart an existing effect or
103
+ // reveal an image that has already completed its exit.
104
+ const previousState=state,job=motion.jobs[0],seed=lastSeed;
105
+ const previousIntent=preserveMotion?(intent??(motion.active&&job?{departing:motion.departing,fromAge:job.fromAge,started:job.started,seed}:null)):null;
106
+ releaseAsset();asset={raster,w,h,rows,filterRadius,aspect,mesh,releaseTexture};object.add(mesh);view.sharedGroups.set('image',{mesh});view.version++;
107
+ view.motionFrame={x:-w/2,y:-h/2,width:w,height:h};
108
+ state=preserveMotion?previousState:'visible';intent=previousIntent;lastSeed=preserveMotion?seed:null;mesh.visible=state!=='hidden';builds++;loadMs=raster.decodeMs;buildMs=performance.now()-start;runtime.requestRender();return true;
109
+ }catch(e){if(disposed||token!==revision||e.name==='AbortError')return false;error=e.message;throw e;}
110
+ finally{if(token===revision){loading=false;runtime.requestRender();}}
111
+ }
112
+ function play(departing,started){
113
+ if(disposed||!asset)return false;
114
+ if(departing&&(state==='hidden'||state==='leaving'||intent?.departing))return false;
115
+ const fromAge=state==='entering'&&motion.jobs[0]?Math.max(0,Math.min(1,((motion.lastTime??motion.jobs[0].started)-motion.jobs[0].started)/motion.duration)):1;
116
+ intent={departing,fromAge,started};runtime.requestRender();return true;
117
+ }
118
+ const control={object,
119
+ frame(now,reducedMotion){
120
+ if(disposed||!asset)return;
121
+ resizeMesh();
122
+ if(intent){
123
+ const {departing,fromAge,started,seed}=intent;intent=null;asset.mesh.visible=true;state=departing?'leaving':'entering';
124
+ const exitMode=entrySettings.exitEffect==='same'?entryMode:entrySettings.exitEffect;
125
+ const activeMode=departing?exitMode:entryMode;
126
+ motion.setMode(activeMode);motion.configure(normalizeImageEffectOptions(requestedSettings,activeMode));
127
+ if(seed!==undefined)lastSeed=seed;else if(!departing||lastSeed===null)lastSeed=motion.randomSeed();
128
+ // DOM image coordinates are source pixels, unlike the spatial runtime's
129
+ // world units. Keep travel at a stable 32 CSS px for DOM presentations.
130
+ const shownHeight=displaySize?.height??measure(object,runtime.camera,runtime.renderer.domElement,asset.h);
131
+ const shownWidth=displaySize?.width??shownHeight*asset.w/asset.h;
132
+ const motionUnit=shownHeight>0?imageMotionPixels(shownWidth,shownHeight)*asset.h/shownHeight:.65;
133
+ motion.playRegion(view,{x:-asset.w/2,y:-asset.h/2,width:asset.w,height:asset.h,direction},motionUnit,started??now,{departing,seed:lastSeed,fromAge:departing&&exitMode!==entryMode?1:fromAge});
134
+ }
135
+ if(motion.active){
136
+ motion.step(now,reducedMotion);
137
+ // This surface reuses one material for both directions. Refresh its
138
+ // dynamic clock/mask uniforms on the first draw after a transition too.
139
+ asset.mesh.material.uniformsNeedUpdate=true;
140
+ // Upload this frame's region data before reusing the same sampler.
141
+ runtime.renderer.initTexture?.(motion.uniforms.dustData.value);
142
+ if(motion.active)runtime.requestRender();
143
+ else{state=motion.departing?'hidden':'visible';asset.mesh.visible=!motion.departing;}
144
+ }
145
+ resizeMesh();
146
+ },
147
+ invalidate(){runtime.requestRender();},
148
+ destroy(){if(disposed)return;disposed=true;revision++;controller?.abort();unregister();releaseAsset();motion.dispose();state='disposed';},
149
+ };
150
+ const unregister=runtime.register(control);
151
+ return {object,ready:source===undefined?Promise.resolve(false):setSource(source),setSource,
152
+ enter:started=>play(false,started),exit:started=>play(true,started),
153
+ setEffect(mode,options={}){validMode(mode);const next=normalizeImageEffectOptions(options,mode);if(disposed)return;requestedSettings=options;entryMode=mode;entrySettings=next;motion.setMode(mode);motion.configure(next);motion.cancel();motion.uniforms.dustMaskOnly.value=0;intent=null;if(asset){asset.mesh.visible=true;state='visible';}runtime.requestRender();},
154
+ setDisplaySize(width,height){if(!(Number.isFinite(width)&&Number.isFinite(height)&&width>0&&height>0))throw RangeError('Positive display dimensions required');displaySize={width,height};},
155
+ setDirection(value){validDirection(value);direction=value;},
156
+ setPresentationOpacity(value){if(asset)asset.mesh.material.uniforms.presentationOpacity.value=value;},
157
+ show(){if(disposed||!asset)return;intent=null;motion.cancel();motion.uniforms.dustMaskOnly.value=0;asset.mesh.visible=true;state='visible';runtime.requestRender();},
158
+ frame:control.frame,destroy:control.destroy,
159
+ stats(){const raster=asset?.raster,g=asset?.mesh.geometry;return {disposed,loading,error,state,active:motion.active,mode:entryMode,activeMode:motion.mode,exitMode:entrySettings.exitEffect==='same'?entryMode:entrySettings.exitEffect,duration:motion.duration,settings:{...motion.settings,recipe:{...motion.settings.recipe}},direction,motionFrame:asset?{...view.motionFrame}:null,divisions:asset?.rows,gridAspect:asset?.aspect,gridColumns:asset?Math.max(1,Math.round(asset.rows*asset.aspect)):0,triangles:g?g.getAttribute('position').count/3:0,builds,loadMs,buildMs,rasterBytes:raster?.rgba.byteLength||0,estimatedTextureBytes:raster?Math.ceil(raster.rgba.byteLength*4/3):0,geometryBytes:g?Object.values(g.attributes).reduce((n,a)=>n+a.array.byteLength,0):0,source:raster?{type:raster.type,width:raster.width,height:raster.height,originalWidth:raster.originalWidth,originalHeight:raster.originalHeight}:null};},
160
+ };
161
+ }
158
162
 
159
163
 
160
164
 
@@ -1,29 +1,29 @@
1
- // UTF-16 edit range shared by both adapters. The selection disambiguates repeats.
2
- export function insertedRange(before,after,inputType='insertText'){
3
- if(!before||!after||!/^insert/.test(inputType)||before.text===after.text)return null;
4
- const start=before.start??0,end=before.end??start;
5
- const length=after.text.length-(before.text.length-(end-start));
6
- if(length>0&&after.text.slice(0,start)===before.text.slice(0,start)&&after.text.slice(start+length)===before.text.slice(end))return {start,end:start+length};
7
- let left=0,right=0;
8
- while(left<before.text.length&&left<after.text.length&&before.text[left]===after.text[left])left++;
9
- while(right<before.text.length-left&&right<after.text.length-left&&before.text[before.text.length-1-right]===after.text[after.text.length-1-right])right++;
10
- return after.text.length-right>left?{start:left,end:after.text.length-right}:null;
11
- }
12
-
13
- // Keep the surviving pieces of an older insertion when a later edit shifts it.
14
- export function remapInsertionRange(range,start,oldEnd,delta){
15
- const pieces=[];
16
- if(range.start<start)pieces.push({start:range.start,end:Math.min(range.end,start)});
17
- if(range.end>oldEnd)pieces.push({start:Math.max(range.start,oldEnd)+delta,end:range.end+delta});
18
- return pieces.filter(piece=>piece.end>piece.start);
19
- }
20
-
21
- export function removedRange(before,after,inputType=''){
22
- if(!before||!after||!/^delete/.test(inputType))return null;
23
- const length=before.text.length-after.text.length;if(length<=0)return null;
24
- const caret=before.start??0;
25
- const start=before.end>caret?caret:/Backward$/.test(inputType)?Math.max(0,caret-length):caret;
26
- if(before.text.slice(0,start)+before.text.slice(start+length)===after.text)return {start,end:start+length};
27
- let left=0;while(left<after.text.length&&before.text[left]===after.text[left])left++;
28
- return {start:left,end:left+length};
29
- }
1
+ // UTF-16 edit range shared by both adapters. The selection disambiguates repeats.
2
+ export function insertedRange(before,after,inputType='insertText'){
3
+ if(!before||!after||!/^insert/.test(inputType)||before.text===after.text)return null;
4
+ const start=before.start??0,end=before.end??start;
5
+ const length=after.text.length-(before.text.length-(end-start));
6
+ if(length>0&&after.text.slice(0,start)===before.text.slice(0,start)&&after.text.slice(start+length)===before.text.slice(end))return {start,end:start+length};
7
+ let left=0,right=0;
8
+ while(left<before.text.length&&left<after.text.length&&before.text[left]===after.text[left])left++;
9
+ while(right<before.text.length-left&&right<after.text.length-left&&before.text[before.text.length-1-right]===after.text[after.text.length-1-right])right++;
10
+ return after.text.length-right>left?{start:left,end:after.text.length-right}:null;
11
+ }
12
+
13
+ // Keep the surviving pieces of an older insertion when a later edit shifts it.
14
+ export function remapInsertionRange(range,start,oldEnd,delta){
15
+ const pieces=[];
16
+ if(range.start<start)pieces.push({start:range.start,end:Math.min(range.end,start)});
17
+ if(range.end>oldEnd)pieces.push({start:Math.max(range.start,oldEnd)+delta,end:range.end+delta});
18
+ return pieces.filter(piece=>piece.end>piece.start);
19
+ }
20
+
21
+ export function removedRange(before,after,inputType=''){
22
+ if(!before||!after||!/^delete/.test(inputType))return null;
23
+ const length=before.text.length-after.text.length;if(length<=0)return null;
24
+ const caret=before.start??0;
25
+ const start=before.end>caret?caret:/Backward$/.test(inputType)?Math.max(0,caret-length):caret;
26
+ if(before.text.slice(0,start)+before.text.slice(start+length)===after.text)return {start,end:start+length};
27
+ let left=0;while(left<after.text.length&&before.text[left]===after.text[left])left++;
28
+ return {start:left,end:left+length};
29
+ }
@@ -1,79 +1,79 @@
1
- // Text and image surfaces share one texture per raster (not per triangle).
2
- // References include deletion ghosts, which can outlive their source scene mesh.
3
- const surfaces=new WeakMap(),materials=new WeakMap();
4
- export function attachRasterTexture(THREE,material,raster,{lodBias=raster.mask===false?0:-.75}={}){
5
- if(!Number.isFinite(lodBias))throw TypeError('A finite raster LOD bias is required');
6
- let surface=surfaces.get(raster);
7
- if(!surface){
8
- const texture=new THREE.DataTexture(raster.rgba,raster.width,raster.height,THREE.RGBAFormat,THREE.UnsignedByteType);
9
- if(raster.mask===false)texture.colorSpace=THREE.SRGBColorSpace;
10
- // One bilinear mip fetch while moving; the shader adds the adjacent mip only
11
- // near the resting pose. Sampling policy never mutates a shared texture.
12
- texture.minFilter=THREE.LinearMipmapNearestFilter;texture.magFilter=THREE.LinearFilter;texture.generateMipmaps=true;texture.needsUpdate=true;
13
- surface={texture,refs:0,raster};surfaces.set(raster,surface);
14
- }
15
- material.uniforms.rasterMap={value:surface.texture};
16
- material.uniforms.rasterSize={value:new THREE.Vector2(raster.width,raster.height)};
17
- material.uniforms.rasterLodBias={value:lodBias};
18
- return lease(material,surface);
19
- }
20
- // Derivatives describe this triangle's current projected texture footprint.
21
- // Explicit LOD is core in WebGL2 (Three aliases texture2DLodEXT) and optional in
22
- // WebGL1. Without it, keep a single conventional texture lookup as a safe fallback.
23
- const rasterSamplingShader=`
24
- uniform vec2 rasterSize;
25
- uniform float rasterLodBias;
26
- varying float vRasterQuality;
27
- vec4 thdRasterSample(vec2 uv){
28
- #if __VERSION__ >= 300 || defined(GL_EXT_shader_texture_lod)
29
- vec2 dx=dFdx(uv*rasterSize),dy=dFdy(uv*rasterSize);
30
- float footprint=max(max(dot(dx,dx),dot(dy,dy)),0.00000001);
31
- float lastLevel=floor(log2(max(max(rasterSize.x,rasterSize.y),1.0)));
32
- float lod=clamp(0.5*log2(footprint)+rasterLodBias,0.0,lastLevel);
33
- float primary=floor(lod+0.5);
34
- vec4 sampleColor=texture2DLodEXT(rasterMap,uv,primary);
35
- float weight=abs(lod-primary)*clamp(vRasterQuality,0.0,1.0);
36
- if(weight>0.0){
37
- float adjacent=primary<lod?primary+1.0:primary-1.0;
38
- sampleColor=mix(sampleColor,texture2DLodEXT(rasterMap,uv,adjacent),weight);
39
- }
40
- return sampleColor;
41
- #else
42
- return texture2D(rasterMap,uv);
43
- #endif
44
- }
45
- `;
46
-
47
- /** Add the shared sampler to text/image shaders that already declare rasterMap
48
- * and vRasterUV. The default is settled; TriangleEffect supplies each facet's
49
- * existing phase without another clock, attribute buffer or per-frame JS work. */
50
- export function applyRasterSamplingShader(material){
51
- material.defines={...material.defines,THD_RASTER_TEXTURE:1};
52
- material.extensions={...material.extensions,derivatives:true,shaderTextureLOD:true};
53
- material.vertexShader='varying float vRasterQuality;\n'+material.vertexShader;
54
- material.vertexShader=material.vertexShader.replace(/void main\(\)\s*\{/,'void main(){ vRasterQuality=1.0;');
55
- const main=material.fragmentShader.indexOf('void main');
56
- material.fragmentShader=material.fragmentShader.slice(0,main)+rasterSamplingShader+material.fragmentShader.slice(main);
57
- material.fragmentShader=material.fragmentShader.replace('texture2D(rasterMap,vRasterUV)','thdRasterSample(vRasterUV)');
58
- }
59
- function lease(material,surface){
60
- surface.refs++;materials.set(material,surface);let released=false;
61
- return ()=>{if(released)return;released=true;materials.delete(material);if(--surface.refs===0){surface.texture.dispose();surfaces.delete(surface.raster);}};
62
- }
63
- export function retainRasterTexture(source,target){
64
- const surface=materials.get(source);if(!surface)return null;
65
- // ShaderMaterial.clone clones texture uniforms. Use the shared source instead
66
- // of uploading an identical new image for each partial deletion.
67
- if(target.uniforms.rasterMap.value!==surface.texture)target.uniforms.rasterMap.value.dispose();
68
- target.uniforms.rasterMap.value=surface.texture;
69
- return lease(target,surface);
70
- }
71
- export function applyRasterTextureShader(material,{mask=true}={}){
72
- material.vertexShader='attribute vec2 rasterUV; varying vec2 vRasterUV;\n'+material.vertexShader;
73
- material.vertexShader=material.vertexShader.replace('void main() {','void main() { vRasterUV=rasterUV;');
74
- material.fragmentShader='uniform sampler2D rasterMap; varying vec2 vRasterUV;\n'+material.fragmentShader;
75
- material.fragmentShader=material.fragmentShader.replace('float coverage =', 'vec4 rasterSample=texture2D(rasterMap,vRasterUV); float coverage =');
76
- material.fragmentShader=material.fragmentShader.replace('max(max(vColor.r, vColor.g), vColor.b) * brightness','rasterSample.a * max(max(vColor.r, vColor.g), vColor.b) * min(brightness, 1.0)');
77
- if(!mask)material.fragmentShader=material.fragmentShader.replace('vec4(tint * min(1.0, diffuse + sheen), coverage)', 'vec4(rasterSample.rgb * tint * min(1.0, diffuse + sheen), coverage)');
78
- applyRasterSamplingShader(material);
79
- }
1
+ // Text and image surfaces share one texture per raster (not per triangle).
2
+ // References include deletion ghosts, which can outlive their source scene mesh.
3
+ const surfaces=new WeakMap(),materials=new WeakMap();
4
+ export function attachRasterTexture(THREE,material,raster,{lodBias=raster.mask===false?0:-.75}={}){
5
+ if(!Number.isFinite(lodBias))throw TypeError('A finite raster LOD bias is required');
6
+ let surface=surfaces.get(raster);
7
+ if(!surface){
8
+ const texture=new THREE.DataTexture(raster.rgba,raster.width,raster.height,THREE.RGBAFormat,THREE.UnsignedByteType);
9
+ if(raster.mask===false)texture.colorSpace=THREE.SRGBColorSpace;
10
+ // One bilinear mip fetch while moving; the shader adds the adjacent mip only
11
+ // near the resting pose. Sampling policy never mutates a shared texture.
12
+ texture.minFilter=THREE.LinearMipmapNearestFilter;texture.magFilter=THREE.LinearFilter;texture.generateMipmaps=true;texture.needsUpdate=true;
13
+ surface={texture,refs:0,raster};surfaces.set(raster,surface);
14
+ }
15
+ material.uniforms.rasterMap={value:surface.texture};
16
+ material.uniforms.rasterSize={value:new THREE.Vector2(raster.width,raster.height)};
17
+ material.uniforms.rasterLodBias={value:lodBias};
18
+ return lease(material,surface);
19
+ }
20
+ // Derivatives describe this triangle's current projected texture footprint.
21
+ // Explicit LOD is core in WebGL2 (Three aliases texture2DLodEXT) and optional in
22
+ // WebGL1. Without it, keep a single conventional texture lookup as a safe fallback.
23
+ const rasterSamplingShader=`
24
+ uniform vec2 rasterSize;
25
+ uniform float rasterLodBias;
26
+ varying float vRasterQuality;
27
+ vec4 thdRasterSample(vec2 uv){
28
+ #if __VERSION__ >= 300 || defined(GL_EXT_shader_texture_lod)
29
+ vec2 dx=dFdx(uv*rasterSize),dy=dFdy(uv*rasterSize);
30
+ float footprint=max(max(dot(dx,dx),dot(dy,dy)),0.00000001);
31
+ float lastLevel=floor(log2(max(max(rasterSize.x,rasterSize.y),1.0)));
32
+ float lod=clamp(0.5*log2(footprint)+rasterLodBias,0.0,lastLevel);
33
+ float primary=floor(lod+0.5);
34
+ vec4 sampleColor=texture2DLodEXT(rasterMap,uv,primary);
35
+ float weight=abs(lod-primary)*clamp(vRasterQuality,0.0,1.0);
36
+ if(weight>0.0){
37
+ float adjacent=primary<lod?primary+1.0:primary-1.0;
38
+ sampleColor=mix(sampleColor,texture2DLodEXT(rasterMap,uv,adjacent),weight);
39
+ }
40
+ return sampleColor;
41
+ #else
42
+ return texture2D(rasterMap,uv);
43
+ #endif
44
+ }
45
+ `;
46
+
47
+ /** Add the shared sampler to text/image shaders that already declare rasterMap
48
+ * and vRasterUV. The default is settled; TriangleEffect supplies each facet's
49
+ * existing phase without another clock, attribute buffer or per-frame JS work. */
50
+ export function applyRasterSamplingShader(material){
51
+ material.defines={...material.defines,THD_RASTER_TEXTURE:1};
52
+ material.extensions={...material.extensions,derivatives:true,shaderTextureLOD:true};
53
+ material.vertexShader='varying float vRasterQuality;\n'+material.vertexShader;
54
+ material.vertexShader=material.vertexShader.replace(/void main\(\)\s*\{/,'void main(){ vRasterQuality=1.0;');
55
+ const main=material.fragmentShader.indexOf('void main');
56
+ material.fragmentShader=material.fragmentShader.slice(0,main)+rasterSamplingShader+material.fragmentShader.slice(main);
57
+ material.fragmentShader=material.fragmentShader.replace('texture2D(rasterMap,vRasterUV)','thdRasterSample(vRasterUV)');
58
+ }
59
+ function lease(material,surface){
60
+ surface.refs++;materials.set(material,surface);let released=false;
61
+ return ()=>{if(released)return;released=true;materials.delete(material);if(--surface.refs===0){surface.texture.dispose();surfaces.delete(surface.raster);}};
62
+ }
63
+ export function retainRasterTexture(source,target){
64
+ const surface=materials.get(source);if(!surface)return null;
65
+ // ShaderMaterial.clone clones texture uniforms. Use the shared source instead
66
+ // of uploading an identical new image for each partial deletion.
67
+ if(target.uniforms.rasterMap.value!==surface.texture)target.uniforms.rasterMap.value.dispose();
68
+ target.uniforms.rasterMap.value=surface.texture;
69
+ return lease(target,surface);
70
+ }
71
+ export function applyRasterTextureShader(material,{mask=true}={}){
72
+ material.vertexShader='attribute vec2 rasterUV; varying vec2 vRasterUV;\n'+material.vertexShader;
73
+ material.vertexShader=material.vertexShader.replace('void main() {','void main() { vRasterUV=rasterUV;');
74
+ material.fragmentShader='uniform sampler2D rasterMap; varying vec2 vRasterUV;\n'+material.fragmentShader;
75
+ material.fragmentShader=material.fragmentShader.replace('float coverage =', 'vec4 rasterSample=texture2D(rasterMap,vRasterUV); float coverage =');
76
+ material.fragmentShader=material.fragmentShader.replace('max(max(vColor.r, vColor.g), vColor.b) * brightness','rasterSample.a * max(max(vColor.r, vColor.g), vColor.b) * min(brightness, 1.0)');
77
+ if(!mask)material.fragmentShader=material.fragmentShader.replace('vec4(tint * min(1.0, diffuse + sheen), coverage)', 'vec4(rasterSample.rgb * tint * min(1.0, diffuse + sheen), coverage)');
78
+ applyRasterSamplingShader(material);
79
+ }