Add WebGL FPS cap and texture word reveal
This commit is contained in:
@@ -24,6 +24,11 @@ class BookTextureRendererModule extends BaseModule {
|
||||
left: [],
|
||||
right: []
|
||||
};
|
||||
this.currentSpread = null;
|
||||
this.activeAnimations = new Map();
|
||||
this.animationFrameId = null;
|
||||
this.lastAnimationFrameAt = 0;
|
||||
this.targetFrameDurationMs = 1000 / 30;
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
@@ -33,6 +38,12 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'drawPageBase',
|
||||
'drawPageLines',
|
||||
'drawLine',
|
||||
'drawWord',
|
||||
'startRevealAnimation',
|
||||
'fastForwardAnimations',
|
||||
'stopAnimations',
|
||||
'requestAnimationFrame',
|
||||
'tickAnimations',
|
||||
'publishSpread',
|
||||
'getPageCanvas',
|
||||
'getHitMap',
|
||||
@@ -51,6 +62,15 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
|
||||
this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.());
|
||||
});
|
||||
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
|
||||
this.startRevealAnimation(event.detail || {});
|
||||
});
|
||||
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
||||
this.addEventListener(document, 'ui:command', (event) => {
|
||||
if (event.detail?.type === 'continue') this.fastForwardAnimations();
|
||||
});
|
||||
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
|
||||
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
|
||||
this.reportProgress(100, 'Book texture renderer ready');
|
||||
return true;
|
||||
}
|
||||
@@ -73,10 +93,11 @@ class BookTextureRendererModule extends BaseModule {
|
||||
}
|
||||
|
||||
drawSpread(spread = null) {
|
||||
this.currentSpread = spread || { left: [], right: [] };
|
||||
this.drawPageBase('left');
|
||||
this.drawPageBase('right');
|
||||
this.drawPageLines('left', spread?.left || []);
|
||||
this.drawPageLines('right', spread?.right || []);
|
||||
this.drawPageLines('left', this.currentSpread?.left || []);
|
||||
this.drawPageLines('right', this.currentSpread?.right || []);
|
||||
this.publishSpread();
|
||||
}
|
||||
|
||||
@@ -112,6 +133,8 @@ class BookTextureRendererModule extends BaseModule {
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||
lines.forEach(line => this.drawLine(ctx, line));
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -133,6 +156,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
? Math.max(0, (metrics.content.width - naturalWidth) / 2)
|
||||
: Number(line.offset || 0);
|
||||
let x = metrics.content.x + centerOffset;
|
||||
let wordIndex = 0;
|
||||
|
||||
ctx.font = `${fontStyle}${fontPx}px ${metrics.typography.fontFamily}`;
|
||||
nodes.forEach((node, index) => {
|
||||
@@ -140,8 +164,9 @@ class BookTextureRendererModule extends BaseModule {
|
||||
if (node.type === 'box' && node.value) {
|
||||
const nextNode = nodes[index + 1];
|
||||
const value = `${node.value}${nextNode?.type === 'penalty' && nextNode.penalty === 100 ? '-' : ''}`;
|
||||
ctx.fillText(value, x, baseY);
|
||||
this.drawWord(ctx, value, x, baseY, lineRecord, wordIndex);
|
||||
x += Number(node.width || ctx.measureText(value).width || 0);
|
||||
wordIndex += 1;
|
||||
} else if (node.type === 'glue' && node.width !== 0) {
|
||||
let width = Number(node.width || 0);
|
||||
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
|
||||
@@ -151,6 +176,99 @@ class BookTextureRendererModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) {
|
||||
const animation = this.activeAnimations.get(String(lineRecord.blockId ?? ''));
|
||||
if (!animation) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillText(value, x, baseY);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalWordIndex = Number(lineRecord.blockWordStart || 0) + localWordIndex;
|
||||
const timing = animation.wordTimings[globalWordIndex];
|
||||
if (!timing) {
|
||||
ctx.globalAlpha = animation.completed ? 1 : 0;
|
||||
ctx.fillText(value, x, baseY);
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = animation.completed
|
||||
? Number.POSITIVE_INFINITY
|
||||
: performance.now() - animation.startedAt;
|
||||
const duration = Math.max(1, Number(timing.duration || 1));
|
||||
const progress = Math.max(0, Math.min(1, (elapsed - Number(timing.delay || 0)) / duration));
|
||||
if (progress <= 0) return;
|
||||
|
||||
const previousAlpha = ctx.globalAlpha;
|
||||
ctx.globalAlpha = previousAlpha * progress;
|
||||
ctx.fillText(value, x, baseY);
|
||||
ctx.globalAlpha = previousAlpha;
|
||||
}
|
||||
|
||||
startRevealAnimation(detail = {}) {
|
||||
const blockId = detail.blockId ?? detail.id ?? null;
|
||||
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
||||
this.activeAnimations.set(String(blockId), {
|
||||
blockId,
|
||||
wordTimings: detail.wordTimings,
|
||||
startedAt: performance.now(),
|
||||
completed: false
|
||||
});
|
||||
this.requestAnimationFrame();
|
||||
}
|
||||
|
||||
fastForwardAnimations() {
|
||||
let changed = false;
|
||||
this.activeAnimations.forEach((animation) => {
|
||||
if (!animation.completed) {
|
||||
animation.completed = true;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
||||
}
|
||||
}
|
||||
|
||||
stopAnimations() {
|
||||
this.activeAnimations.clear();
|
||||
if (this.animationFrameId) {
|
||||
clearTimeout(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
||||
}
|
||||
|
||||
requestAnimationFrame() {
|
||||
if (this.animationFrameId) return;
|
||||
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
|
||||
}
|
||||
|
||||
tickAnimations(now) {
|
||||
this.animationFrameId = null;
|
||||
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
|
||||
this.requestAnimationFrame();
|
||||
return;
|
||||
}
|
||||
this.lastAnimationFrameAt = now;
|
||||
|
||||
let hasActive = false;
|
||||
const currentNow = performance.now();
|
||||
this.activeAnimations.forEach((animation) => {
|
||||
if (animation.completed) return;
|
||||
const lastTiming = animation.wordTimings.at(-1);
|
||||
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
|
||||
if (currentNow - animation.startedAt >= total + 50) {
|
||||
animation.completed = true;
|
||||
} else {
|
||||
hasActive = true;
|
||||
}
|
||||
});
|
||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
||||
if (hasActive) this.requestAnimationFrame();
|
||||
}
|
||||
|
||||
publishSpread() {
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
|
||||
detail: {
|
||||
|
||||
Reference in New Issue
Block a user