@ev-ry/fx 0.1.0-rc.1 → 0.1.0-rc.3
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.
- package/QUICKSTART.fa.md +99 -87
- package/README.md +120 -116
- package/build-report.json +48 -43
- package/docs/GUIDE.md +122 -112
- package/docs/RELEASE-NOTES.md +74 -39
- package/examples/evry-website.md +58 -0
- package/package.json +1 -1
- package/src/dom-attachment.d.ts +1 -1
- package/src/dom-free.d.ts +14 -14
- package/src/dom-free.js +61 -38
- package/src/dom-image-raster.js +14 -0
- package/src/dom-image-surface.js +150 -103
- package/src/dom-image-swap.js +5 -2
- package/src/dom-once.js +39 -34
- package/src/dom-reveal.js +83 -73
- package/src/dom-rich-text.js +133 -116
- package/src/dom-surface-font.js +4 -4
- package/src/dom-svg-surface.js +41 -39
- package/src/dom-text-fingerprint.js +27 -27
- package/src/dom-text-paint-mask.js +24 -0
- package/src/dom-text-surface.js +280 -236
- package/src/font-rasterizer.js +18 -7
- package/src/image-surface.js +161 -156
- package/src/render-owner.js +17 -12
- package/src/text-motion-primitives.js +7 -0
- package/src/text-scene.js +41 -39
- package/src/triangle-effect.js +2 -1
- package/src/viewport-render-owner.js +322 -316
package/src/font-rasterizer.js
CHANGED
|
@@ -15,8 +15,8 @@ export class FontRasterizer {
|
|
|
15
15
|
this.context.font = this.font;
|
|
16
16
|
// Build the cached source for legibility, independently of the GPU's cheaper
|
|
17
17
|
// moving-particle sampler. Reapply after canvas resize resets drawing state.
|
|
18
|
-
if ('textRendering' in this.context) this.context.textRendering = 'optimizeLegibility';
|
|
19
|
-
if ('fontKerning' in this.context) this.context.fontKerning = 'normal';
|
|
18
|
+
if ('textRendering' in this.context) this.context.textRendering = this.textRendering ?? 'optimizeLegibility';
|
|
19
|
+
if ('fontKerning' in this.context) this.context.fontKerning = this.fontKerning ?? 'normal';
|
|
20
20
|
if ('letterSpacing' in this.context) this.context.letterSpacing = `${this.letterSpacing || 0}px`;
|
|
21
21
|
if ('wordSpacing' in this.context) this.context.wordSpacing = `${this.wordSpacing || 0}px`;
|
|
22
22
|
this.context.textAlign = 'left'; this.context.textBaseline = 'alphabetic';
|
|
@@ -29,16 +29,27 @@ export class FontRasterizer {
|
|
|
29
29
|
const key = direction + ':' + text;
|
|
30
30
|
if (this.cache.has(key)) return this.cache.get(key);
|
|
31
31
|
this.configure(direction);
|
|
32
|
-
const metric = this.context.measureText(text);
|
|
32
|
+
const metric = this.context.measureText(text);
|
|
33
|
+
let horizontalScale = 1;
|
|
34
|
+
if (this.displayFontSize > 0 && metric.width > 0) {
|
|
35
|
+
const factor = this.displayFontSize / 200;
|
|
36
|
+
this.context.font = this.font.replace('200px', `${this.displayFontSize}px`);
|
|
37
|
+
if ('letterSpacing' in this.context) this.context.letterSpacing = `${(this.letterSpacing || 0) * factor}px`;
|
|
38
|
+
if ('wordSpacing' in this.context) this.context.wordSpacing = `${(this.wordSpacing || 0) * factor}px`;
|
|
39
|
+
const advance = this.context.measureText(text).width / factor;
|
|
40
|
+
if (Number.isFinite(advance) && advance > 0) horizontalScale = advance / metric.width;
|
|
41
|
+
this.configure(direction);
|
|
42
|
+
}
|
|
33
43
|
const padding = 2;
|
|
34
|
-
const drawOffsetX = Math.ceil(Math.max(0, metric.actualBoundingBoxLeft || 0)) + padding;
|
|
44
|
+
const drawOffsetX = Math.ceil(Math.max(0, (metric.actualBoundingBoxLeft || 0)*horizontalScale)) + padding;
|
|
35
45
|
const baseline = Math.ceil(Math.max(this.ascent, metric.actualBoundingBoxAscent || 0)) + padding;
|
|
36
|
-
const width = Math.max(2, Math.ceil(Math.max(metric.width, metric.actualBoundingBoxRight || 0) + drawOffsetX + padding));
|
|
46
|
+
const width = Math.max(2, Math.ceil(Math.max(metric.width, metric.actualBoundingBoxRight || 0)*horizontalScale + drawOffsetX + padding));
|
|
37
47
|
const height = Math.max(2, Math.ceil(baseline + Math.max(this.descent, metric.actualBoundingBoxDescent || 0) + padding));
|
|
38
48
|
this.canvas.width = width; this.canvas.height = height; this.configure(direction);
|
|
39
|
-
this.context.
|
|
49
|
+
this.context.save();this.context.translate(drawOffsetX,baseline);this.context.scale(horizontalScale,1);
|
|
50
|
+
this.context.fillText(text,0,0);this.context.restore();
|
|
40
51
|
const rgba = this.context.getImageData(0, 0, width, height).data;
|
|
41
|
-
const result = { width, height, baseline, drawOffsetX, advance: metric.width, rgba };
|
|
52
|
+
const result = { width, height, baseline, drawOffsetX, advance: metric.width*horizontalScale, rgba };
|
|
42
53
|
this.cache.set(key, result);
|
|
43
54
|
return result;
|
|
44
55
|
}
|
package/src/image-surface.js
CHANGED
|
@@ -1,159 +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
|
|
12
|
-
export function normalizeImageEffectOptions(settings={},mode='dust-wind'){
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
attribute vec2
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
let
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if(disposed
|
|
110
|
-
if(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
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
|
+
}
|
|
157
162
|
|
|
158
163
|
|
|
159
164
|
|
package/src/render-owner.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {createViewportRenderOwner} from './viewport-render-owner.js';
|
|
2
|
-
|
|
3
|
-
// Shared GPU ownership, independent of component factories. Local 2D
|
|
4
|
-
// presentation canvases preserve DOM stacking and clipping of each surface.
|
|
5
|
-
export function createRenderOwner(THREE,document,{presentation='local',...options}={}){
|
|
6
|
-
if(presentation==='viewport')return createViewportRenderOwner(THREE,document,options);
|
|
7
|
-
if(presentation!=='local')throw TypeError('Invalid canvas presentation');
|
|
1
|
+
import {createViewportRenderOwner} from './viewport-render-owner.js';
|
|
2
|
+
|
|
3
|
+
// Shared GPU ownership, independent of component factories. Local 2D
|
|
4
|
+
// presentation canvases preserve DOM stacking and clipping of each surface.
|
|
5
|
+
export function createRenderOwner(THREE,document,{presentation='local',...options}={}){
|
|
6
|
+
if(presentation==='viewport')return createViewportRenderOwner(THREE,document,options);
|
|
7
|
+
if(presentation!=='local')throw TypeError('Invalid canvas presentation');
|
|
8
8
|
if(!THREE?.WebGLRenderer||!document?.defaultView)throw TypeError('THREE and a window document required');
|
|
9
9
|
let renderer=null,disposed=false,lost=false,w=0,h=0,dpr=0,copies=0,copyMs=0,renderMs=0,created=0;
|
|
10
10
|
const leases=new Set(),handles=new Set();
|
|
@@ -29,11 +29,16 @@ export function createRenderOwner(THREE,document,{presentation='local',...option
|
|
|
29
29
|
}
|
|
30
30
|
render(scene,camera){
|
|
31
31
|
ensure();if(this.dead)throw Error('Presentation disposed');
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Keep a high-water scratch buffer: alternating differently sized leases
|
|
33
|
+
// must not reallocate the GPU drawing buffer on every text frame.
|
|
34
|
+
const pw=this.domElement.width,ph=this.domElement.height;
|
|
35
|
+
if(pw>w||ph>h){w=Math.max(w,pw);h=Math.max(h,ph);renderer.setSize(w,h,false);}
|
|
36
|
+
renderer.setScissorTest(false);renderer.clear(true,true,true);
|
|
37
|
+
renderer.setViewport(0,0,pw,ph);
|
|
38
|
+
renderer.setScissor(0,0,pw,ph);renderer.setScissorTest(true);
|
|
34
39
|
const t=performance.now();renderer.render(scene,camera);renderMs+=performance.now()-t;
|
|
35
40
|
const start=performance.now();this.context.clearRect(0,0,this.domElement.width,this.domElement.height);
|
|
36
|
-
this.context.drawImage(renderer.domElement,0,0);copyMs+=performance.now()-start;copies++;
|
|
41
|
+
this.context.drawImage(renderer.domElement,0,h-ph,pw,ph,0,0,pw,ph);copyMs+=performance.now()-start;copies++;
|
|
37
42
|
}
|
|
38
43
|
dispose(){if(this.dead)return;this.dead=true;leases.delete(this);this.domElement.width=this.domElement.height=0;}
|
|
39
44
|
forceContextLoss(){} // A lease cannot destroy a peer's context.
|
|
@@ -44,8 +49,8 @@ export function createRenderOwner(THREE,document,{presentation='local',...option
|
|
|
44
49
|
const control=factory(host,Namespace,options),destroy=control.destroy;
|
|
45
50
|
handles.add(control);control.destroy=()=>{handles.delete(control);destroy();};return control;
|
|
46
51
|
}
|
|
47
|
-
return {create,
|
|
48
|
-
refresh(){if(disposed)throw Error('Shared owner disposed');for(const control of handles)control.refresh?.();},
|
|
52
|
+
return {create,
|
|
53
|
+
refresh(){if(disposed)throw Error('Shared owner disposed');for(const control of handles)control.refresh?.();},
|
|
49
54
|
stats:()=>({disposed,lost,contexts:renderer&&!disposed?1:0,created,controls:handles.size,leases:leases.size,copies,copyMs,renderMs,geometries:renderer?.info.memory.geometries||0}),
|
|
50
55
|
destroy(){
|
|
51
56
|
if(disposed)return;disposed=true;
|
|
@@ -33,6 +33,13 @@ export const textMotionPrimitives=`
|
|
|
33
33
|
float delay=clamp(order,0.0,1.0)*spread;
|
|
34
34
|
return clamp((t-delay)/max(1.0-delay,0.001),0.0,1.0);
|
|
35
35
|
}
|
|
36
|
+
// Continuous per-particle appearance: sparse at first, progressively denser.
|
|
37
|
+
// Use the existing reversible motion phase so exit follows the same envelope.
|
|
38
|
+
float thdParticleAppearance(float t,float order){
|
|
39
|
+
float start=0.55*sqrt(clamp(order,0.0,1.0));
|
|
40
|
+
float fade=clamp((t-start)/0.25,0.0,1.0);
|
|
41
|
+
return fade*fade;
|
|
42
|
+
}
|
|
36
43
|
vec2 thdTurn(vec2 v,float angle){
|
|
37
44
|
return vec2(v.x*cos(angle)-v.y*sin(angle),v.x*sin(angle)+v.y*cos(angle));
|
|
38
45
|
}
|
package/src/text-scene.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { MOTION_SAMPLES, RadialMotion } from './motion.js';
|
|
2
|
-
import {attachRasterTexture,applyRasterTextureShader} from './raster-texture-material.js';
|
|
1
|
+
import { MOTION_SAMPLES, RadialMotion } from './motion.js';
|
|
2
|
+
import {attachRasterTexture,applyRasterTextureShader} from './raster-texture-material.js';
|
|
3
3
|
|
|
4
4
|
const vertexShader = `
|
|
5
5
|
attribute vec3 color;
|
|
@@ -25,9 +25,10 @@ const vertexShader = `
|
|
|
25
25
|
gl_Position = projectionMatrix * viewPosition;
|
|
26
26
|
}
|
|
27
27
|
`;
|
|
28
|
-
const fragmentShader = `
|
|
29
|
-
uniform float brightness;
|
|
30
|
-
uniform
|
|
28
|
+
const fragmentShader = `
|
|
29
|
+
uniform float brightness;
|
|
30
|
+
uniform float presentationOpacity;
|
|
31
|
+
uniform vec3 tint;
|
|
31
32
|
varying vec3 vColor;
|
|
32
33
|
varying vec3 vViewPosition;
|
|
33
34
|
void main() {
|
|
@@ -36,12 +37,13 @@ const fragmentShader = `
|
|
|
36
37
|
float diffuse = mix(0.16, 1.0, pow(facing, 0.72));
|
|
37
38
|
float sheen = 0.16 * pow(facing, 10.0);
|
|
38
39
|
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
|
-
|
|
40
|
+
// Coverage belongs in alpha only; multiplying RGB by it as well darkens thin strokes twice.
|
|
41
|
+
gl_FragColor = vec4(tint * min(1.0, diffuse + sheen), coverage);
|
|
42
|
+
// CSS tint and sampled image RGB are linear in Three's working space.
|
|
43
|
+
// Encode RGB once for the render target; coverage alpha stays unchanged.
|
|
44
|
+
#include <colorspace_fragment>
|
|
45
|
+
gl_FragColor.a *= presentationOpacity;
|
|
46
|
+
}
|
|
45
47
|
`;
|
|
46
48
|
|
|
47
49
|
export class TextScene {
|
|
@@ -55,7 +57,7 @@ export class TextScene {
|
|
|
55
57
|
this.glyphs = [];
|
|
56
58
|
this.version = 0;
|
|
57
59
|
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) } };
|
|
60
|
+
this.uniforms = { presentationOpacity:{value:1}, angles: { value: this.motion.angles }, radius: { value: 1 }, effectTime: { value: 2 }, brightness: { value: 1.25 }, tint: { value: new THREE.Color(1,1,1) } };
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
setText(text) {
|
|
@@ -182,7 +184,7 @@ export class TextScene {
|
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
186
|
|
|
185
|
-
// Shared path: one draw per distinct glyph, with one XY offset per occurrence.
|
|
187
|
+
// Shared path: one draw per distinct glyph, with one XY offset per occurrence.
|
|
186
188
|
export class SharedTextScene extends TextScene {
|
|
187
189
|
setText(text) {
|
|
188
190
|
const THREE = this.THREE, scale = this.engine.scale;
|
|
@@ -205,7 +207,7 @@ export class SharedTextScene extends TextScene {
|
|
|
205
207
|
}
|
|
206
208
|
const previous = this.sharedGroups || new Map(), next = new Map();
|
|
207
209
|
this.bounds = {minX:Infinity,maxX:-Infinity,minY:Infinity,maxY:-Infinity};
|
|
208
|
-
this.bufferBytes = 0;this.textureBytes=0;
|
|
210
|
+
this.bufferBytes = 0;this.textureBytes=0;
|
|
209
211
|
for (const [glyph, offsets] of groups) {
|
|
210
212
|
let resource = previous.get(glyph);
|
|
211
213
|
if (resource && resource.scale !== scale) { this.releaseShared(resource); resource=null; }
|
|
@@ -219,35 +221,35 @@ export class SharedTextScene extends TextScene {
|
|
|
219
221
|
const shader = vertexShader.replace('attribute float delay;', 'attribute float delay; attribute vec2 glyphOffset;')
|
|
220
222
|
.replace('position + vec3(0.0, baseline, 0.0)', 'position + vec3(glyphOffset, 0.0)')
|
|
221
223
|
.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
|
-
}
|
|
224
|
+
const material = new THREE.ShaderMaterial({uniforms:{...this.uniforms,baseline:{value:0}},
|
|
225
|
+
vertexShader:shader,fragmentShader,side:THREE.DoubleSide,transparent:true,depthWrite:false,extensions:{derivatives:true}});
|
|
226
|
+
let releaseTexture=null;
|
|
227
|
+
if(glyph.rasterSurface){
|
|
228
|
+
geometry.setAttribute('rasterUV',new THREE.BufferAttribute(glyph.meshData.uvs,2));
|
|
229
|
+
applyRasterTextureShader(material,{mask:glyph.rasterSurface.mask!==false});
|
|
230
|
+
releaseTexture=attachRasterTexture(THREE,material,glyph.rasterSurface);
|
|
231
|
+
}
|
|
230
232
|
const mesh = new THREE.Mesh(geometry,material);mesh.frustumCulled=false;this.scene.add(mesh);
|
|
231
|
-
resource={mesh,scale,releaseTexture};
|
|
233
|
+
resource={mesh,scale,releaseTexture};
|
|
232
234
|
}
|
|
233
235
|
const geometry=resource.mesh.geometry;
|
|
234
236
|
let attribute=geometry.getAttribute('glyphOffset');
|
|
235
237
|
// 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
|
+
if (!attribute || attribute.count < offsets.length/2 ||
|
|
239
|
+
(attribute.array.length > 32 && offsets.length < attribute.array.length / 4)) {
|
|
238
240
|
// Replacing a BufferAttribute does not release its old GPU buffer in Three.
|
|
239
241
|
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
|
-
}
|
|
242
|
+
const capacity = 2 ** Math.ceil(Math.log2(Math.max(8, offsets.length)));
|
|
243
|
+
attribute=new THREE.InstancedBufferAttribute(new Float32Array(capacity),2);
|
|
244
|
+
geometry.setAttribute('glyphOffset',attribute);
|
|
245
|
+
}
|
|
246
|
+
let changed = geometry.instanceCount !== offsets.length / 2 || attribute.version === 0;
|
|
247
|
+
for (let i=0; i<offsets.length && !changed; i++) changed = attribute.array[i] !== Math.fround(offsets[i]);
|
|
248
|
+
if (changed) {
|
|
249
|
+
attribute.array.set(offsets);
|
|
250
|
+
attribute.updateRange.offset=0; attribute.updateRange.count=offsets.length;
|
|
251
|
+
attribute.needsUpdate=true;
|
|
252
|
+
}
|
|
251
253
|
geometry.instanceCount=offsets.length/2;
|
|
252
254
|
const box=geometry.boundingBox;
|
|
253
255
|
for(let i=0;i<offsets.length;i+=2){
|
|
@@ -256,8 +258,8 @@ export class SharedTextScene extends TextScene {
|
|
|
256
258
|
this.bounds.minY=Math.min(this.bounds.minY,box.min.y+offsets[i+1]);
|
|
257
259
|
this.bounds.maxY=Math.max(this.bounds.maxY,box.max.y+offsets[i+1]);
|
|
258
260
|
}
|
|
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
|
+
for(const attr of Object.values(geometry.attributes))this.bufferBytes+=attr.array.byteLength;
|
|
262
|
+
if(glyph.rasterSurface)this.textureBytes+=glyph.rasterSurface.rgba.byteLength;
|
|
261
263
|
next.set(glyph,resource);
|
|
262
264
|
}
|
|
263
265
|
for(const [glyph,resource] of previous)if(!next.has(glyph))this.releaseShared(resource);
|
|
@@ -267,6 +269,6 @@ export class SharedTextScene extends TextScene {
|
|
|
267
269
|
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
270
|
this.uniforms.radius.value=this.motion.radius;this.effectStarted=null;this.uniforms.effectTime.value=2;this.version++;
|
|
269
271
|
}
|
|
270
|
-
releaseShared(resource){this.scene.remove(resource.mesh);resource.mesh.geometry.dispose();resource.mesh.material.dispose();resource.releaseTexture?.();}
|
|
272
|
+
releaseShared(resource){this.scene.remove(resource.mesh);resource.mesh.geometry.dispose();resource.mesh.material.dispose();resource.releaseTexture?.();}
|
|
271
273
|
dispose(){for(const resource of this.sharedGroups?.values() || [])this.releaseShared(resource);this.sharedGroups?.clear();super.dispose();}
|
|
272
274
|
}
|
package/src/triangle-effect.js
CHANGED
|
@@ -139,8 +139,9 @@ export class TriangleEffect {
|
|
|
139
139
|
pace=clamp(mix(0.5,pace,dustChaos),0.0,1.0);
|
|
140
140
|
}
|
|
141
141
|
float remain=1.0-t;
|
|
142
|
-
dustOpacity=
|
|
142
|
+
dustOpacity=1.0;
|
|
143
143
|
${textEffectApplicationShader}
|
|
144
|
+
dustOpacity*=thdParticleAppearance(t,fract(seed*17.17+drift*3.13));
|
|
144
145
|
p.z=thdFrontDepth(position.z,p.z);
|
|
145
146
|
break;
|
|
146
147
|
}
|