Queue WebGL book reveal masks
@@ -280,6 +280,6 @@
|
|||||||
console.log(message);
|
console.log(message);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/js/loader.js?v=20260607-webgl-shader-reveal"></script>
|
<script type="module" src="/js/loader.js?v=20260607-webgl-queued-mask-reveal"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Defines the canonical page geometry used by the WebGL book renderer.
|
* Defines the canonical page geometry used by the WebGL book renderer.
|
||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-shader-reveal';
|
import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-queued-mask-reveal';
|
||||||
|
|
||||||
export const BOOK_TEXTURE_WIDTH = 3072;
|
export const BOOK_TEXTURE_WIDTH = 3072;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'initialize',
|
'initialize',
|
||||||
'refreshFromHistory',
|
'refreshFromHistory',
|
||||||
|
'preparePendingBlock',
|
||||||
'buildSpreads',
|
'buildSpreads',
|
||||||
'layoutTextBlock',
|
'layoutTextBlock',
|
||||||
'getDropCapText',
|
'getDropCapText',
|
||||||
@@ -50,6 +51,9 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.reportProgress(35, 'Preparing book pagination metrics');
|
this.reportProgress(35, 'Preparing book pagination metrics');
|
||||||
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
||||||
this.addEventListener(document, 'story:history-updated', this.refreshFromHistory);
|
this.addEventListener(document, 'story:history-updated', this.refreshFromHistory);
|
||||||
|
this.addEventListener(document, 'book-pagination:prepare-block', (event) => {
|
||||||
|
this.preparePendingBlock(event.detail?.block || event.detail || {});
|
||||||
|
});
|
||||||
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
|
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
|
||||||
this.setCurrentSpread(event.detail?.spreadIndex);
|
this.setCurrentSpread(event.detail?.spreadIndex);
|
||||||
});
|
});
|
||||||
@@ -91,6 +95,54 @@ class BookPaginationModule extends BaseModule {
|
|||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async preparePendingBlock(block = {}) {
|
||||||
|
const token = ++this.refreshToken;
|
||||||
|
const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null;
|
||||||
|
const latestRenderedBlockId = Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0));
|
||||||
|
const pendingBlockId = Math.max(0, Number(block.blockId || block.metadata?.blockId || 0));
|
||||||
|
if (!gameId || pendingBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyBlocks = latestRenderedBlockId > 0
|
||||||
|
? await this.storyHistory.getBlocksRange(gameId, 1, latestRenderedBlockId)
|
||||||
|
: [];
|
||||||
|
if (token !== this.refreshToken) return null;
|
||||||
|
|
||||||
|
const normalizedBlock = {
|
||||||
|
...block,
|
||||||
|
type: block.kind || block.type || 'paragraph',
|
||||||
|
kind: block.kind || block.type || 'paragraph',
|
||||||
|
blockId: pendingBlockId,
|
||||||
|
gameId,
|
||||||
|
metadata: {
|
||||||
|
...(block.metadata || {}),
|
||||||
|
blockId: pendingBlockId,
|
||||||
|
gameId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.latestBlockId = pendingBlockId;
|
||||||
|
this.latestRenderedBlockId = latestRenderedBlockId;
|
||||||
|
this.spreads = this.buildSpreads([...historyBlocks, normalizedBlock]);
|
||||||
|
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
|
||||||
|
const targetSpread = this.spreads.find(spread => ['left', 'right'].some(side => {
|
||||||
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
||||||
|
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
|
||||||
|
}));
|
||||||
|
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
||||||
|
this.publish();
|
||||||
|
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
|
||||||
|
detail: {
|
||||||
|
blockId: pendingBlockId,
|
||||||
|
spread: this.getCurrentSpread(),
|
||||||
|
spreadIndex: this.currentSpreadIndex,
|
||||||
|
latestBlockId: this.latestBlockId,
|
||||||
|
latestRenderedBlockId: this.latestRenderedBlockId
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return this.getCurrentSpread();
|
||||||
|
}
|
||||||
|
|
||||||
buildSpreads(blocks = []) {
|
buildSpreads(blocks = []) {
|
||||||
const spreads = [];
|
const spreads = [];
|
||||||
let cursorLine = 0;
|
let cursorLine = 0;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.revealedBlockIds = new Set();
|
this.revealedBlockIds = new Set();
|
||||||
this.pendingRevealBlockIds = new Set();
|
this.pendingRevealBlockIds = new Set();
|
||||||
this.revealBounds = null;
|
this.revealBounds = null;
|
||||||
|
this.revealWords = null;
|
||||||
this.revealPublishBlockIds = null;
|
this.revealPublishBlockIds = null;
|
||||||
this.animationFrameId = null;
|
this.animationFrameId = null;
|
||||||
this.lastAnimationFrameAt = 0;
|
this.lastAnimationFrameAt = 0;
|
||||||
@@ -47,6 +48,8 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'getPageContent',
|
'getPageContent',
|
||||||
'buildLineSegments',
|
'buildLineSegments',
|
||||||
'startRevealAnimation',
|
'startRevealAnimation',
|
||||||
|
'prepareRevealBlock',
|
||||||
|
'startPreparedRevealAnimation',
|
||||||
'fastForwardAnimations',
|
'fastForwardAnimations',
|
||||||
'stopAnimations',
|
'stopAnimations',
|
||||||
'getBlockSides',
|
'getBlockSides',
|
||||||
@@ -90,6 +93,9 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
|
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
|
||||||
this.startRevealAnimation(event.detail || {});
|
this.startRevealAnimation(event.detail || {});
|
||||||
});
|
});
|
||||||
|
this.addEventListener(document, 'book-texture:prepare-reveal-block', (event) => {
|
||||||
|
this.prepareRevealBlock(event.detail || {});
|
||||||
|
});
|
||||||
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
||||||
this.addEventListener(document, 'ui:command', (event) => {
|
this.addEventListener(document, 'ui:command', (event) => {
|
||||||
if (event.detail?.type === 'continue') this.fastForwardAnimations();
|
if (event.detail?.type === 'continue') this.fastForwardAnimations();
|
||||||
@@ -121,6 +127,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.currentSpread = spread || { left: [], right: [] };
|
this.currentSpread = spread || { left: [], right: [] };
|
||||||
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
||||||
this.revealBounds = { left: null, right: null };
|
this.revealBounds = { left: null, right: null };
|
||||||
|
this.revealWords = { left: [], right: [] };
|
||||||
sidesToDraw.forEach((side) => {
|
sidesToDraw.forEach((side) => {
|
||||||
if (!this.canvases[side]) return;
|
if (!this.canvases[side]) return;
|
||||||
this.drawPageBase(side);
|
this.drawPageBase(side);
|
||||||
@@ -128,6 +135,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
this.publishSpread(sidesToDraw);
|
this.publishSpread(sidesToDraw);
|
||||||
this.revealBounds = null;
|
this.revealBounds = null;
|
||||||
|
this.revealWords = null;
|
||||||
this.revealPublishBlockIds = null;
|
this.revealPublishBlockIds = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +210,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
|
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
|
||||||
this.recordRevealRect(side, lineRecord, dropCapX, dropCapY, fontPx * 2.9, dropCapFontPx * 0.9);
|
this.recordRevealRect(side, lineRecord, dropCapX, dropCapY, fontPx * 2.9, dropCapFontPx * 0.9, 0);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||||
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
||||||
@@ -301,11 +309,33 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
...nextRect,
|
...nextRect,
|
||||||
blockIds: new Set([blockId])
|
blockIds: new Set([blockId])
|
||||||
};
|
};
|
||||||
|
const globalWordIndex = Math.max(0, Number(lineRecord.blockWordStart || 0) + Number(localWordIndex || 0));
|
||||||
|
const timing = Array.isArray(animation.wordTimings) ? animation.wordTimings[globalWordIndex] : null;
|
||||||
|
if (!timing || !this.revealWords?.[side]) return;
|
||||||
|
this.revealWords[side].push({
|
||||||
|
blockId,
|
||||||
|
wordIndex: globalWordIndex,
|
||||||
|
rect: {
|
||||||
|
x: nextRect.x / this.metrics.width,
|
||||||
|
y: nextRect.y / this.metrics.height,
|
||||||
|
width: Math.max(0.001, (nextRect.right - nextRect.x) / this.metrics.width),
|
||||||
|
height: Math.max(0.001, (nextRect.bottom - nextRect.y) / this.metrics.height)
|
||||||
|
},
|
||||||
|
timing: {
|
||||||
|
delay: Math.max(0, Number(timing.delay || 0)),
|
||||||
|
duration: Math.max(1, Number(timing.duration || 1))
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startRevealAnimation(detail = {}) {
|
startRevealAnimation(detail = {}) {
|
||||||
const blockId = detail.blockId ?? detail.id ?? null;
|
const blockId = detail.blockId ?? detail.id ?? null;
|
||||||
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
||||||
|
const existing = this.activeAnimations.get(String(blockId));
|
||||||
|
if (existing && existing.prepared) {
|
||||||
|
this.startPreparedRevealAnimation(blockId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.activeAnimations.set(String(blockId), {
|
this.activeAnimations.set(String(blockId), {
|
||||||
blockId,
|
blockId,
|
||||||
wordTimings: detail.wordTimings,
|
wordTimings: detail.wordTimings,
|
||||||
@@ -319,21 +349,69 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.pendingRevealBlockIds.delete(String(blockId));
|
this.pendingRevealBlockIds.delete(String(blockId));
|
||||||
this.revealPublishBlockIds = new Set([String(blockId)]);
|
this.revealPublishBlockIds = new Set([String(blockId)]);
|
||||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
||||||
|
detail: {
|
||||||
|
blockId
|
||||||
|
}
|
||||||
|
}));
|
||||||
this.requestAnimationFrame();
|
this.requestAnimationFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareRevealBlock(detail = {}) {
|
||||||
|
const blockId = detail.blockId ?? detail.id ?? null;
|
||||||
|
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
||||||
|
const id = String(blockId);
|
||||||
|
const wordTimings = detail.wordTimings;
|
||||||
|
this.activeAnimations.set(id, {
|
||||||
|
blockId,
|
||||||
|
wordTimings,
|
||||||
|
startedAt: null,
|
||||||
|
totalDuration: Math.max(
|
||||||
|
Number(detail.totalDuration || 0),
|
||||||
|
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
|
||||||
|
),
|
||||||
|
completed: false,
|
||||||
|
prepared: true
|
||||||
|
});
|
||||||
|
this.pendingRevealBlockIds.delete(id);
|
||||||
|
this.revealPublishBlockIds = new Set([id]);
|
||||||
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
|
||||||
|
}
|
||||||
|
|
||||||
|
startPreparedRevealAnimation(blockId) {
|
||||||
|
const id = String(blockId ?? '');
|
||||||
|
const animation = this.activeAnimations.get(id);
|
||||||
|
if (!animation) return false;
|
||||||
|
animation.startedAt = performance.now();
|
||||||
|
animation.prepared = false;
|
||||||
|
animation.completed = false;
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
||||||
|
detail: {
|
||||||
|
blockId: animation.blockId
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
this.requestAnimationFrame();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
fastForwardAnimations() {
|
fastForwardAnimations() {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
const blockIds = [];
|
||||||
this.activeAnimations.forEach((animation) => {
|
this.activeAnimations.forEach((animation) => {
|
||||||
if (!animation.completed) {
|
if (!animation.completed) {
|
||||||
animation.completed = true;
|
animation.completed = true;
|
||||||
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
||||||
|
blockIds.push(animation.blockId);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (changed) {
|
if (changed) {
|
||||||
this.pendingRevealBlockIds.clear();
|
this.pendingRevealBlockIds.clear();
|
||||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true));
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
||||||
|
detail: {
|
||||||
|
blockIds
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +471,10 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.activeAnimations.forEach((animation) => {
|
this.activeAnimations.forEach((animation) => {
|
||||||
if (animation.completed) return;
|
if (animation.completed) return;
|
||||||
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
||||||
|
if (animation.startedAt == null) {
|
||||||
|
hasActive = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lastTiming = animation.wordTimings.at(-1);
|
const lastTiming = animation.wordTimings.at(-1);
|
||||||
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
|
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
|
||||||
if (currentNow - animation.startedAt >= total + 50) {
|
if (currentNow - animation.startedAt >= total + 50) {
|
||||||
@@ -426,6 +508,12 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
reveal[side] = {
|
reveal[side] = {
|
||||||
blockIds,
|
blockIds,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
wordRects: (this.revealWords?.[side] || []).map(word => ({
|
||||||
|
blockId: word.blockId,
|
||||||
|
wordIndex: word.wordIndex,
|
||||||
|
rect: word.rect,
|
||||||
|
timing: word.timing
|
||||||
|
})),
|
||||||
bounds: {
|
bounds: {
|
||||||
x: bounds.x / this.metrics.width,
|
x: bounds.x / this.metrics.width,
|
||||||
y: bounds.y / this.metrics.height,
|
y: bounds.y / this.metrics.height,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ModuleState = {
|
|||||||
ERROR: 'ERROR'
|
ERROR: 'ERROR'
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODULE_CACHE_BUSTER = '20260607-webgl-shader-reveal';
|
const MODULE_CACHE_BUSTER = '20260607-webgl-queued-mask-reveal';
|
||||||
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
|
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class PlaybackCoordinatorModule extends BaseModule {
|
|||||||
'play',
|
'play',
|
||||||
'calculateWordTimings',
|
'calculateWordTimings',
|
||||||
'animateWords',
|
'animateWords',
|
||||||
|
'isWebGLPlaybackMode',
|
||||||
|
'scheduleWebGLReveal',
|
||||||
'waitForAudioStart',
|
'waitForAudioStart',
|
||||||
'completeSentenceVisual',
|
'completeSentenceVisual',
|
||||||
'accelerateActiveWordAnimations',
|
'accelerateActiveWordAnimations',
|
||||||
@@ -213,7 +215,7 @@ class PlaybackCoordinatorModule extends BaseModule {
|
|||||||
* @returns {Promise<void>} - Resolves when animation completes
|
* @returns {Promise<void>} - Resolves when animation completes
|
||||||
*/
|
*/
|
||||||
async animateWords(sentence) {
|
async animateWords(sentence) {
|
||||||
if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) {
|
if (!sentence.animation || !sentence.animation.wordTimings) {
|
||||||
console.error('PlaybackCoordinator: Missing animation data');
|
console.error('PlaybackCoordinator: Missing animation data');
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -224,6 +226,15 @@ class PlaybackCoordinatorModule extends BaseModule {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isWebGLPlaybackMode()) {
|
||||||
|
return this.scheduleWebGLReveal(sentence, animQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sentence.element) {
|
||||||
|
console.error('PlaybackCoordinator: Missing DOM element for 2D animation');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const wordElements = sentence.element.querySelectorAll('.word');
|
const wordElements = sentence.element.querySelectorAll('.word');
|
||||||
let wordTimings = sentence.animation.wordTimings;
|
let wordTimings = sentence.animation.wordTimings;
|
||||||
let cueTimings = sentence.animation.cueTimings || [];
|
let cueTimings = sentence.animation.cueTimings || [];
|
||||||
@@ -302,6 +313,57 @@ class PlaybackCoordinatorModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isWebGLPlaybackMode() {
|
||||||
|
return document.body?.dataset?.webglUiMode === '3d'
|
||||||
|
|| document.body?.classList?.contains('webgl-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleWebGLReveal(sentence, animQueue) {
|
||||||
|
let wordTimings = Array.isArray(sentence.animation?.wordTimings)
|
||||||
|
? sentence.animation.wordTimings
|
||||||
|
: [];
|
||||||
|
let cueTimings = Array.isArray(sentence.animation?.cueTimings)
|
||||||
|
? sentence.animation.cueTimings
|
||||||
|
: [];
|
||||||
|
if (wordTimings.length === 0) {
|
||||||
|
const words = String(sentence.text || '').match(/\S+/g) || [];
|
||||||
|
const calculated = this.calculateWordTimings(words, sentence.tts?.duration || sentence.animation?.totalDuration || 0);
|
||||||
|
wordTimings = calculated.wordTimings;
|
||||||
|
cueTimings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('book-texture:reveal-block', {
|
||||||
|
detail: {
|
||||||
|
id: sentence.id,
|
||||||
|
blockId: sentence.blockId ?? sentence.metadata?.blockId ?? null,
|
||||||
|
wordTimings,
|
||||||
|
cueTimings,
|
||||||
|
totalDuration: sentence.animation?.totalDuration || 0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const totalDuration = wordTimings.length > 0
|
||||||
|
? Math.max(...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
cueTimings.forEach(cue => {
|
||||||
|
animQueue.schedule(() => {
|
||||||
|
document.dispatchEvent(new CustomEvent('story:media-cue', {
|
||||||
|
detail: {
|
||||||
|
sentenceId: sentence.id,
|
||||||
|
...cue
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, cue.delay || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
animQueue.schedule(() => {
|
||||||
|
resolve();
|
||||||
|
}, totalDuration + 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate word-level timing to match total TTS duration
|
* Calculate word-level timing to match total TTS duration
|
||||||
* This is a utility method that can be called by SentenceQueue during preparation
|
* This is a utility method that can be called by SentenceQueue during preparation
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
'applyGameConfig',
|
'applyGameConfig',
|
||||||
'applyTranslations',
|
'applyTranslations',
|
||||||
'renderSentence',
|
'renderSentence',
|
||||||
|
'isWebGLMode',
|
||||||
|
'prepareWebGLBookReveal',
|
||||||
'renderStoryBlock',
|
'renderStoryBlock',
|
||||||
'prepareRenderableBlock',
|
'prepareRenderableBlock',
|
||||||
'prepareTextRenderable',
|
'prepareTextRenderable',
|
||||||
@@ -986,12 +988,14 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
if (!isCurrent()) return null;
|
if (!isCurrent()) return null;
|
||||||
this.rebuildLayoutExclusions(this.renderedItems);
|
this.rebuildLayoutExclusions(this.renderedItems);
|
||||||
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
|
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
|
||||||
|
const useWebGLBookReveal = this.isWebGLMode() && (sentence.kind === 'paragraph' || sentence.kind === 'heading');
|
||||||
const element = await this.renderStoryBlock(sentence, {
|
const element = await this.renderStoryBlock(sentence, {
|
||||||
animate: true,
|
animate: true,
|
||||||
playback: true,
|
playback: true,
|
||||||
placement: 'append',
|
placement: 'append',
|
||||||
token: this.renderWindowToken,
|
token: this.renderWindowToken,
|
||||||
generation
|
generation,
|
||||||
|
deferRenderedMark: useWebGLBookReveal
|
||||||
});
|
});
|
||||||
if (!element) return null;
|
if (!element) return null;
|
||||||
if (!isCurrent()) {
|
if (!isCurrent()) {
|
||||||
@@ -1008,7 +1012,13 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
if (sentence.kind === 'image') {
|
if (sentence.kind === 'image') {
|
||||||
this.revealImageBlock(element);
|
this.revealImageBlock(element);
|
||||||
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
||||||
|
if (useWebGLBookReveal) {
|
||||||
|
await this.prepareWebGLBookReveal(sentence);
|
||||||
|
}
|
||||||
await this.playbackCoordinator.play(sentence);
|
await this.playbackCoordinator.play(sentence);
|
||||||
|
if (useWebGLBookReveal && sentence.blockId != null) {
|
||||||
|
this.markBlockRendered(sentence.blockId);
|
||||||
|
}
|
||||||
} else if (sentence.kind === 'music') {
|
} else if (sentence.kind === 'music') {
|
||||||
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
|
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
|
||||||
}
|
}
|
||||||
@@ -1028,6 +1038,42 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isWebGLMode() {
|
||||||
|
return document.body?.dataset?.webglUiMode === '3d'
|
||||||
|
|| document.body?.classList?.contains('webgl-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareWebGLBookReveal(sentence) {
|
||||||
|
const bookPagination = this.getModule('book-pagination');
|
||||||
|
const bookTextureRenderer = this.getModule('book-texture-renderer');
|
||||||
|
if (!bookPagination || !bookTextureRenderer || sentence.blockId == null) return;
|
||||||
|
|
||||||
|
if (typeof bookPagination.preparePendingBlock === 'function') {
|
||||||
|
await bookPagination.preparePendingBlock(sentence);
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
|
||||||
|
detail: {
|
||||||
|
block: sentence
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealDetail = {
|
||||||
|
id: sentence.id,
|
||||||
|
blockId: sentence.blockId,
|
||||||
|
wordTimings: sentence.animation?.wordTimings || [],
|
||||||
|
cueTimings: sentence.animation?.cueTimings || [],
|
||||||
|
totalDuration: sentence.animation?.totalDuration || 0
|
||||||
|
};
|
||||||
|
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
|
||||||
|
bookTextureRenderer.prepareRevealBlock(revealDetail);
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(new CustomEvent('book-texture:prepare-reveal-block', {
|
||||||
|
detail: revealDetail
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async rerenderStory() {
|
async rerenderStory() {
|
||||||
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
||||||
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
||||||
@@ -1097,7 +1143,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
renderedItemsTarget = this.renderedItems,
|
renderedItemsTarget = this.renderedItems,
|
||||||
token = null,
|
token = null,
|
||||||
recordMetrics = true,
|
recordMetrics = true,
|
||||||
generation = this.displayGeneration
|
generation = this.displayGeneration,
|
||||||
|
deferRenderedMark = false
|
||||||
} = options;
|
} = options;
|
||||||
if (!item || !this.paragraphContainer) return null;
|
if (!item || !this.paragraphContainer) return null;
|
||||||
const renderable = await this.prepareRenderableBlock(item);
|
const renderable = await this.prepareRenderableBlock(item);
|
||||||
@@ -1144,7 +1191,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
if (item.blockId != null) {
|
if (item.blockId != null) {
|
||||||
element.dataset.storyBlockId = String(item.blockId);
|
element.dataset.storyBlockId = String(item.blockId);
|
||||||
this.markBlockRendered(item.blockId);
|
if (!deferRenderedMark) this.markBlockRendered(item.blockId);
|
||||||
}
|
}
|
||||||
element.dataset.lineStart = String(renderable.lineStart);
|
element.dataset.lineStart = String(renderable.lineStart);
|
||||||
element.dataset.lineCount = String(renderable.lineCount);
|
element.dataset.lineCount = String(renderable.lineCount);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces
|
|||||||
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
|
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
|
||||||
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
|
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
|
||||||
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
|
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
|
||||||
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-shader-reveal';
|
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-queued-mask-reveal';
|
||||||
|
|
||||||
const canvas = document.getElementById('scene');
|
const canvas = document.getElementById('scene');
|
||||||
canvas.style.cursor = 'grab';
|
canvas.style.cursor = 'grab';
|
||||||
@@ -184,17 +184,15 @@ let pendingPageFlips = 0;
|
|||||||
|
|
||||||
const paperColor = new THREE.Color(0xece4ca);
|
const paperColor = new THREE.Color(0xece4ca);
|
||||||
const inkColor = '#1a1009';
|
const inkColor = '#1a1009';
|
||||||
|
const maxRevealWords = 128;
|
||||||
|
const completedRevealElapsedMs = 1000000000;
|
||||||
|
|
||||||
await reportLabStep(48, 'Preparing high-resolution page textures');
|
await reportLabStep(48, 'Preparing high-resolution page textures');
|
||||||
const leftCanvas = createPageCanvas('left');
|
const leftCanvas = createPageCanvas('left');
|
||||||
const rightCanvas = createPageCanvas('right');
|
const rightCanvas = createPageCanvas('right');
|
||||||
const leftRevealCanvas = createPageCanvas('left');
|
|
||||||
const rightRevealCanvas = createPageCanvas('right');
|
|
||||||
const leftTexture = new THREE.CanvasTexture(leftCanvas);
|
const leftTexture = new THREE.CanvasTexture(leftCanvas);
|
||||||
const rightTexture = new THREE.CanvasTexture(rightCanvas);
|
const rightTexture = new THREE.CanvasTexture(rightCanvas);
|
||||||
const leftRevealTexture = new THREE.CanvasTexture(leftRevealCanvas);
|
[leftTexture, rightTexture].forEach((texture) => {
|
||||||
const rightRevealTexture = new THREE.CanvasTexture(rightRevealCanvas);
|
|
||||||
[leftTexture, rightTexture, leftRevealTexture, rightRevealTexture].forEach((texture) => {
|
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
texture.anisotropy = maxTextureAnisotropy;
|
texture.anisotropy = maxTextureAnisotropy;
|
||||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||||
@@ -205,14 +203,6 @@ const pageRevealState = {
|
|||||||
left: null,
|
left: null,
|
||||||
right: null
|
right: null
|
||||||
};
|
};
|
||||||
const pageRevealCanvases = {
|
|
||||||
left: leftRevealCanvas,
|
|
||||||
right: rightRevealCanvas
|
|
||||||
};
|
|
||||||
const pageRevealTextures = {
|
|
||||||
left: leftRevealTexture,
|
|
||||||
right: rightRevealTexture
|
|
||||||
};
|
|
||||||
await reportLabStep(52, 'Generating leather texture set');
|
await reportLabStep(52, 'Generating leather texture set');
|
||||||
const leatherTextures = createLeatherTextures();
|
const leatherTextures = createLeatherTextures();
|
||||||
await reportLabStep(56, 'Generating spine cloth texture set');
|
await reportLabStep(56, 'Generating spine cloth texture set');
|
||||||
@@ -357,12 +347,10 @@ const materials = {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
materials.leftPage.userData.bookPageReveal = {
|
materials.leftPage.userData.bookPageReveal = {
|
||||||
side: 'left',
|
side: 'left'
|
||||||
texture: leftRevealTexture
|
|
||||||
};
|
};
|
||||||
materials.rightPage.userData.bookPageReveal = {
|
materials.rightPage.userData.bookPageReveal = {
|
||||||
side: 'right',
|
side: 'right'
|
||||||
texture: rightRevealTexture
|
|
||||||
};
|
};
|
||||||
materials.spineCloth.userData.isSpineCloth = true;
|
materials.spineCloth.userData.isSpineCloth = true;
|
||||||
materials.headband.userData.isHeadband = true;
|
materials.headband.userData.isHeadband = true;
|
||||||
@@ -475,6 +463,12 @@ window.BookLabDebug = {
|
|||||||
|
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
|
document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
|
||||||
|
document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
||||||
|
startPageRevealForBlock(event.detail?.blockId);
|
||||||
|
});
|
||||||
|
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
||||||
|
fastForwardPageReveals(event.detail?.blockIds || []);
|
||||||
|
});
|
||||||
installBookControls();
|
installBookControls();
|
||||||
installCameraControls();
|
installCameraControls();
|
||||||
resize();
|
resize();
|
||||||
@@ -568,10 +562,12 @@ function configureBookShadowReceiver(material, strength) {
|
|||||||
shader.uniforms.bookShadowReceiverStrength = { value: strength };
|
shader.uniforms.bookShadowReceiverStrength = { value: strength };
|
||||||
shader.uniforms.bookTableTopY = { value: tableTopY };
|
shader.uniforms.bookTableTopY = { value: tableTopY };
|
||||||
if (pageReveal) {
|
if (pageReveal) {
|
||||||
shader.uniforms.bookRevealMap = { value: pageReveal.texture };
|
|
||||||
shader.uniforms.bookRevealActive = { value: 0 };
|
shader.uniforms.bookRevealActive = { value: 0 };
|
||||||
shader.uniforms.bookRevealProgress = { value: 1 };
|
shader.uniforms.bookRevealElapsedMs = { value: completedRevealElapsedMs };
|
||||||
shader.uniforms.bookRevealBounds = { value: new THREE.Vector4(0, 0, 1, 1) };
|
shader.uniforms.bookRevealWordCount = { value: 0 };
|
||||||
|
shader.uniforms.bookRevealWordRects = { value: Array.from({ length: maxRevealWords }, () => new THREE.Vector4(0, 0, 0, 0)) };
|
||||||
|
shader.uniforms.bookRevealWordTimings = { value: Array.from({ length: maxRevealWords }, () => new THREE.Vector4(0, 1, 0, 0)) };
|
||||||
|
shader.uniforms.bookRevealPaperColor = { value: paperColor.clone() };
|
||||||
shader.uniforms.bookRevealSoftness = { value: 0.035 };
|
shader.uniforms.bookRevealSoftness = { value: 0.035 };
|
||||||
material.userData.bookRevealShader = shader;
|
material.userData.bookRevealShader = shader;
|
||||||
}
|
}
|
||||||
@@ -608,18 +604,29 @@ function configureBookShadowReceiver(material, strength) {
|
|||||||
uniform vec2 bookShadowMapTexelSize;
|
uniform vec2 bookShadowMapTexelSize;
|
||||||
uniform float bookShadowReceiverStrength;
|
uniform float bookShadowReceiverStrength;
|
||||||
uniform float bookTableTopY;
|
uniform float bookTableTopY;
|
||||||
${pageReveal ? `uniform sampler2D bookRevealMap;
|
${pageReveal ? `uniform float bookRevealActive;
|
||||||
uniform float bookRevealActive;
|
uniform float bookRevealElapsedMs;
|
||||||
uniform float bookRevealProgress;
|
uniform int bookRevealWordCount;
|
||||||
uniform vec4 bookRevealBounds;
|
uniform vec4 bookRevealWordRects[128];
|
||||||
|
uniform vec4 bookRevealWordTimings[128];
|
||||||
|
uniform vec3 bookRevealPaperColor;
|
||||||
uniform float bookRevealSoftness;
|
uniform float bookRevealSoftness;
|
||||||
|
|
||||||
float bookRevealMask(vec2 uv) {
|
float bookRevealVisibleMask(vec2 uv) {
|
||||||
vec2 local = (uv - bookRevealBounds.xy) / max(bookRevealBounds.zw, vec2(0.0001));
|
float hidden = 0.0;
|
||||||
float inside = step(0.0, local.x) * step(0.0, local.y) * step(local.x, 1.0) * step(local.y, 1.0);
|
for (int i = 0; i < 128; i++) {
|
||||||
float diagonal = clamp((local.x + (1.0 - local.y)) * 0.5, 0.0, 1.0);
|
if (i >= bookRevealWordCount) break;
|
||||||
float feather = max(0.0001, bookRevealSoftness);
|
vec4 rect = bookRevealWordRects[i];
|
||||||
return inside * smoothstep(diagonal - feather, diagonal + feather, bookRevealProgress);
|
vec2 local = (uv - rect.xy) / max(rect.zw, vec2(0.0001));
|
||||||
|
float inside = step(0.0, local.x) * step(0.0, local.y) * step(local.x, 1.0) * step(local.y, 1.0);
|
||||||
|
vec4 timing = bookRevealWordTimings[i];
|
||||||
|
float progress = clamp((bookRevealElapsedMs - timing.x) / max(1.0, timing.y), 0.0, 1.0);
|
||||||
|
float scan = clamp((local.x + (1.0 - local.y)) * 0.5, 0.0, 1.0);
|
||||||
|
float feather = max(0.0001, bookRevealSoftness);
|
||||||
|
float visible = smoothstep(scan - feather, scan + feather, progress);
|
||||||
|
hidden = max(hidden, inside * (1.0 - visible));
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
}` : ''}
|
}` : ''}
|
||||||
varying vec3 vBookReceiverWorldPosition;
|
varying vec3 vBookReceiverWorldPosition;
|
||||||
varying vec3 vBookReceiverWorldNormal;
|
varying vec3 vBookReceiverWorldNormal;
|
||||||
@@ -764,8 +771,10 @@ function configureBookShadowReceiver(material, strength) {
|
|||||||
`#ifdef USE_MAP
|
`#ifdef USE_MAP
|
||||||
vec4 sampledDiffuseColor = texture2D(map, vMapUv);
|
vec4 sampledDiffuseColor = texture2D(map, vMapUv);
|
||||||
if (bookRevealActive > 0.5) {
|
if (bookRevealActive > 0.5) {
|
||||||
vec4 revealDiffuseColor = texture2D(bookRevealMap, vMapUv);
|
float hiddenInk = bookRevealVisibleMask(vMapUv);
|
||||||
sampledDiffuseColor = mix(sampledDiffuseColor, revealDiffuseColor, bookRevealMask(vMapUv));
|
float luminance = dot(sampledDiffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||||
|
float inkMask = 1.0 - smoothstep(0.26, 0.72, luminance);
|
||||||
|
sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, bookRevealPaperColor, hiddenInk * inkMask);
|
||||||
}
|
}
|
||||||
diffuseColor *= sampledDiffuseColor;
|
diffuseColor *= sampledDiffuseColor;
|
||||||
#endif`
|
#endif`
|
||||||
@@ -1640,44 +1649,58 @@ function uploadPageTextureDirect(side, sourceCanvas) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
|
function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
|
||||||
const revealCanvas = pageRevealCanvases[side];
|
const canvas = side === 'left' ? leftCanvas : rightCanvas;
|
||||||
const revealTexture = pageRevealTextures[side];
|
const texture = side === 'left' ? leftTexture : rightTexture;
|
||||||
if (!revealCanvas || !revealTexture) {
|
|
||||||
uploadPageTextureDirect(side, sourceCanvas);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawCanvasPageTexture(revealCanvas, sourceCanvas, side);
|
|
||||||
const shader = getPageRevealShader(side);
|
const shader = getPageRevealShader(side);
|
||||||
if (!shader?.uniforms) {
|
if (!shader?.uniforms) {
|
||||||
uploadPageTextureDirect(side, sourceCanvas);
|
uploadPageTextureDirect(side, sourceCanvas);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bounds = revealDetail.bounds || {};
|
drawCanvasPageTexture(canvas, sourceCanvas, side);
|
||||||
const x = THREE.MathUtils.clamp(Number(bounds.x || 0), 0, 1);
|
texture.needsUpdate = true;
|
||||||
const y = THREE.MathUtils.clamp(Number(bounds.y || 0), 0, 1);
|
applyPageRevealWords(shader, revealDetail.wordRects || []);
|
||||||
const width = THREE.MathUtils.clamp(Number(bounds.width || 1), 0.001, 1);
|
|
||||||
const height = THREE.MathUtils.clamp(Number(bounds.height || 1), 0.001, 1);
|
|
||||||
shader.uniforms.bookRevealBounds.value.set(
|
|
||||||
x,
|
|
||||||
THREE.MathUtils.clamp(1 - y - height, 0, 1),
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
);
|
|
||||||
shader.uniforms.bookRevealProgress.value = 0;
|
|
||||||
shader.uniforms.bookRevealActive.value = 1;
|
shader.uniforms.bookRevealActive.value = 1;
|
||||||
shader.uniforms.bookRevealMap.value = revealTexture;
|
shader.uniforms.bookRevealElapsedMs.value = 0;
|
||||||
revealTexture.needsUpdate = true;
|
|
||||||
|
|
||||||
pageRevealState[side] = {
|
pageRevealState[side] = {
|
||||||
startedAt: performance.now(),
|
startedAt: revealDetail.startNow ? performance.now() : null,
|
||||||
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
|
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
|
||||||
revealCanvas,
|
|
||||||
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []
|
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPageRevealWords(shader, words = []) {
|
||||||
|
const rectUniforms = shader.uniforms.bookRevealWordRects.value;
|
||||||
|
const timingUniforms = shader.uniforms.bookRevealWordTimings.value;
|
||||||
|
const source = Array.isArray(words) ? words.slice(0, maxRevealWords) : [];
|
||||||
|
shader.uniforms.bookRevealWordCount.value = source.length;
|
||||||
|
source.forEach((word, index) => {
|
||||||
|
const rect = word.rect || {};
|
||||||
|
const timing = word.timing || {};
|
||||||
|
const x = THREE.MathUtils.clamp(Number(rect.x || 0), 0, 1);
|
||||||
|
const y = THREE.MathUtils.clamp(Number(rect.y || 0), 0, 1);
|
||||||
|
const width = THREE.MathUtils.clamp(Number(rect.width || 0), 0, 1);
|
||||||
|
const height = THREE.MathUtils.clamp(Number(rect.height || 0), 0, 1);
|
||||||
|
rectUniforms[index].set(
|
||||||
|
x,
|
||||||
|
THREE.MathUtils.clamp(1 - y - height, 0, 1),
|
||||||
|
Math.max(0.0001, width),
|
||||||
|
Math.max(0.0001, height)
|
||||||
|
);
|
||||||
|
timingUniforms[index].set(
|
||||||
|
Math.max(0, Number(timing.delay || 0)),
|
||||||
|
Math.max(1, Number(timing.duration || 1)),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (let index = source.length; index < maxRevealWords; index += 1) {
|
||||||
|
rectUniforms[index].set(0, 0, 0, 0);
|
||||||
|
timingUniforms[index].set(0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getPageRevealShader(side) {
|
function getPageRevealShader(side) {
|
||||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||||
return material?.userData?.bookRevealShader || null;
|
return material?.userData?.bookRevealShader || null;
|
||||||
@@ -1688,10 +1711,34 @@ function clearPageReveal(side) {
|
|||||||
const shader = getPageRevealShader(side);
|
const shader = getPageRevealShader(side);
|
||||||
if (shader?.uniforms?.bookRevealActive) {
|
if (shader?.uniforms?.bookRevealActive) {
|
||||||
shader.uniforms.bookRevealActive.value = 0;
|
shader.uniforms.bookRevealActive.value = 0;
|
||||||
shader.uniforms.bookRevealProgress.value = 1;
|
shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs;
|
||||||
|
shader.uniforms.bookRevealWordCount.value = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startPageRevealForBlock(blockId) {
|
||||||
|
const id = String(blockId ?? '');
|
||||||
|
['left', 'right'].forEach((side) => {
|
||||||
|
const state = pageRevealState[side];
|
||||||
|
if (!state || state.startedAt != null) return;
|
||||||
|
if (!state.blockIds.map(value => String(value)).includes(id)) return;
|
||||||
|
state.startedAt = performance.now();
|
||||||
|
const shader = getPageRevealShader(side);
|
||||||
|
if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fastForwardPageReveals(blockIds = []) {
|
||||||
|
const ids = new Set((Array.isArray(blockIds) ? blockIds : []).map(value => String(value)));
|
||||||
|
['left', 'right'].forEach((side) => {
|
||||||
|
const state = pageRevealState[side];
|
||||||
|
if (!state) return;
|
||||||
|
const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId)));
|
||||||
|
if (!matches) return;
|
||||||
|
clearPageReveal(side);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updatePageRevealAnimations(now) {
|
function updatePageRevealAnimations(now) {
|
||||||
['left', 'right'].forEach((side) => {
|
['left', 'right'].forEach((side) => {
|
||||||
const state = pageRevealState[side];
|
const state = pageRevealState[side];
|
||||||
@@ -1701,14 +1748,14 @@ function updatePageRevealAnimations(now) {
|
|||||||
clearPageReveal(side);
|
clearPageReveal(side);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.startedAt == null) {
|
||||||
|
shader.uniforms.bookRevealElapsedMs.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const progress = THREE.MathUtils.clamp((now - state.startedAt) / state.durationMs, 0, 1);
|
const progress = THREE.MathUtils.clamp((now - state.startedAt) / state.durationMs, 0, 1);
|
||||||
shader.uniforms.bookRevealProgress.value = progress;
|
shader.uniforms.bookRevealElapsedMs.value = Math.max(0, now - state.startedAt);
|
||||||
if (progress < 1) return;
|
if (progress < 1) return;
|
||||||
|
|
||||||
const canvas = side === 'left' ? leftCanvas : rightCanvas;
|
|
||||||
const texture = side === 'left' ? leftTexture : rightTexture;
|
|
||||||
drawCanvasPageTexture(canvas, state.revealCanvas, side);
|
|
||||||
texture.needsUpdate = true;
|
|
||||||
clearPageReveal(side);
|
clearPageReveal(side);
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
|
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
|
||||||
detail: {
|
detail: {
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ const sourcePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-lab.js
|
|||||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
const proceduralBookPath = path.join(__dirname, '..', 'public', 'js', 'procedural-book-model.js');
|
const proceduralBookPath = path.join(__dirname, '..', 'public', 'js', 'procedural-book-model.js');
|
||||||
const proceduralBookSource = fs.readFileSync(proceduralBookPath, 'utf8');
|
const proceduralBookSource = fs.readFileSync(proceduralBookPath, 'utf8');
|
||||||
|
const textureRendererPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-renderer-module.js');
|
||||||
|
const textureRendererSource = fs.readFileSync(textureRendererPath, 'utf8');
|
||||||
|
const playbackCoordinatorPath = path.join(__dirname, '..', 'public', 'js', 'playback-coordinator-module.js');
|
||||||
|
const playbackCoordinatorSource = fs.readFileSync(playbackCoordinatorPath, 'utf8');
|
||||||
|
const uiDisplayHandlerPath = path.join(__dirname, '..', 'public', 'js', 'ui-display-handler-module.js');
|
||||||
|
const uiDisplayHandlerSource = fs.readFileSync(uiDisplayHandlerPath, 'utf8');
|
||||||
|
const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagination-module.js');
|
||||||
|
const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8');
|
||||||
|
|
||||||
const checks = [
|
const checks = [
|
||||||
['scene-level SSAO import', /SSAOPass/.test(source)],
|
['scene-level SSAO import', /SSAOPass/.test(source)],
|
||||||
@@ -29,7 +37,13 @@ const checks = [
|
|||||||
['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)],
|
['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)],
|
||||||
['debug AO remains scene-level', /scene debug: SSAO/.test(source)],
|
['debug AO remains scene-level', /scene debug: SSAO/.test(source)],
|
||||||
['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)],
|
['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)],
|
||||||
['render readiness flag is exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source)]
|
['render readiness flag is exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source)],
|
||||||
|
['3D playback bypasses DOM word animation scheduling', /isWebGLPlaybackMode/.test(playbackCoordinatorSource) && /if \(this\.isWebGLPlaybackMode\(\)\)/.test(playbackCoordinatorSource) && /scheduleWebGLReveal/.test(playbackCoordinatorSource)],
|
||||||
|
['3D UI defers rendered history mark until playback completes', /deferRenderedMark/.test(uiDisplayHandlerSource) && /prepareWebGLBookReveal/.test(uiDisplayHandlerSource) && /markBlockRendered\(sentence\.blockId/.test(uiDisplayHandlerSource)],
|
||||||
|
['pagination can build a pending unrendered 3D block', /preparePendingBlock/.test(bookPaginationSource) && /book-pagination:prepare-block/.test(bookPaginationSource)],
|
||||||
|
['texture renderer has separate prepare and start reveal phases', /prepareRevealBlock/.test(textureRendererSource) && /startPreparedRevealAnimation/.test(textureRendererSource) && /webgl-book:page-reveal-start/.test(textureRendererSource)],
|
||||||
|
['texture renderer publishes per-word reveal coordinates', /revealWords/.test(textureRendererSource) && /wordRects/.test(textureRendererSource) && /blockWordStart/.test(textureRendererSource)],
|
||||||
|
['page reveal shader uses coordinate mask instead of comparing page textures', /bookRevealWordRects/.test(source) && /bookRevealWordTimings/.test(source) && /bookRevealElapsedMs/.test(source) && !/texture2D\(bookRevealMap/.test(source)]
|
||||||
];
|
];
|
||||||
|
|
||||||
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
|
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 906 KiB |