Queue WebGL book reveal masks
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* Defines the canonical page geometry used by the WebGL book renderer.
|
||||
*/
|
||||
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;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class BookPaginationModule extends BaseModule {
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'refreshFromHistory',
|
||||
'preparePendingBlock',
|
||||
'buildSpreads',
|
||||
'layoutTextBlock',
|
||||
'getDropCapText',
|
||||
@@ -50,6 +51,9 @@ class BookPaginationModule extends BaseModule {
|
||||
this.reportProgress(35, 'Preparing book pagination metrics');
|
||||
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
||||
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.setCurrentSpread(event.detail?.spreadIndex);
|
||||
});
|
||||
@@ -91,6 +95,54 @@ class BookPaginationModule extends BaseModule {
|
||||
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 = []) {
|
||||
const spreads = [];
|
||||
let cursorLine = 0;
|
||||
|
||||
@@ -29,6 +29,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.revealedBlockIds = new Set();
|
||||
this.pendingRevealBlockIds = new Set();
|
||||
this.revealBounds = null;
|
||||
this.revealWords = null;
|
||||
this.revealPublishBlockIds = null;
|
||||
this.animationFrameId = null;
|
||||
this.lastAnimationFrameAt = 0;
|
||||
@@ -47,6 +48,8 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'getPageContent',
|
||||
'buildLineSegments',
|
||||
'startRevealAnimation',
|
||||
'prepareRevealBlock',
|
||||
'startPreparedRevealAnimation',
|
||||
'fastForwardAnimations',
|
||||
'stopAnimations',
|
||||
'getBlockSides',
|
||||
@@ -90,6 +93,9 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
|
||||
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, 'ui:command', (event) => {
|
||||
if (event.detail?.type === 'continue') this.fastForwardAnimations();
|
||||
@@ -121,6 +127,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.currentSpread = spread || { left: [], right: [] };
|
||||
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
||||
this.revealBounds = { left: null, right: null };
|
||||
this.revealWords = { left: [], right: [] };
|
||||
sidesToDraw.forEach((side) => {
|
||||
if (!this.canvases[side]) return;
|
||||
this.drawPageBase(side);
|
||||
@@ -128,6 +135,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
});
|
||||
this.publishSpread(sidesToDraw);
|
||||
this.revealBounds = null;
|
||||
this.revealWords = null;
|
||||
this.revealPublishBlockIds = null;
|
||||
}
|
||||
|
||||
@@ -202,7 +210,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
||||
ctx.textBaseline = 'top';
|
||||
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();
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
||||
@@ -301,11 +309,33 @@ class BookTextureRendererModule extends BaseModule {
|
||||
...nextRect,
|
||||
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 = {}) {
|
||||
const blockId = detail.blockId ?? detail.id ?? null;
|
||||
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), {
|
||||
blockId,
|
||||
wordTimings: detail.wordTimings,
|
||||
@@ -319,21 +349,69 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.pendingRevealBlockIds.delete(String(blockId));
|
||||
this.revealPublishBlockIds = new Set([String(blockId)]);
|
||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
||||
detail: {
|
||||
blockId
|
||||
}
|
||||
}));
|
||||
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() {
|
||||
let changed = false;
|
||||
const blockIds = [];
|
||||
this.activeAnimations.forEach((animation) => {
|
||||
if (!animation.completed) {
|
||||
animation.completed = true;
|
||||
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
||||
blockIds.push(animation.blockId);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
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) => {
|
||||
if (animation.completed) 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 total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
|
||||
if (currentNow - animation.startedAt >= total + 50) {
|
||||
@@ -426,6 +508,12 @@ class BookTextureRendererModule extends BaseModule {
|
||||
reveal[side] = {
|
||||
blockIds,
|
||||
durationMs,
|
||||
wordRects: (this.revealWords?.[side] || []).map(word => ({
|
||||
blockId: word.blockId,
|
||||
wordIndex: word.wordIndex,
|
||||
rect: word.rect,
|
||||
timing: word.timing
|
||||
})),
|
||||
bounds: {
|
||||
x: bounds.x / this.metrics.width,
|
||||
y: bounds.y / this.metrics.height,
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const ModuleState = {
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
'play',
|
||||
'calculateWordTimings',
|
||||
'animateWords',
|
||||
'isWebGLPlaybackMode',
|
||||
'scheduleWebGLReveal',
|
||||
'waitForAudioStart',
|
||||
'completeSentenceVisual',
|
||||
'accelerateActiveWordAnimations',
|
||||
@@ -213,7 +215,7 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
* @returns {Promise<void>} - Resolves when animation completes
|
||||
*/
|
||||
async animateWords(sentence) {
|
||||
if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) {
|
||||
if (!sentence.animation || !sentence.animation.wordTimings) {
|
||||
console.error('PlaybackCoordinator: Missing animation data');
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -224,6 +226,15 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
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');
|
||||
let wordTimings = sentence.animation.wordTimings;
|
||||
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
|
||||
* This is a utility method that can be called by SentenceQueue during preparation
|
||||
|
||||
@@ -68,6 +68,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
'applyGameConfig',
|
||||
'applyTranslations',
|
||||
'renderSentence',
|
||||
'isWebGLMode',
|
||||
'prepareWebGLBookReveal',
|
||||
'renderStoryBlock',
|
||||
'prepareRenderableBlock',
|
||||
'prepareTextRenderable',
|
||||
@@ -986,12 +988,14 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
if (!isCurrent()) return null;
|
||||
this.rebuildLayoutExclusions(this.renderedItems);
|
||||
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
|
||||
const useWebGLBookReveal = this.isWebGLMode() && (sentence.kind === 'paragraph' || sentence.kind === 'heading');
|
||||
const element = await this.renderStoryBlock(sentence, {
|
||||
animate: true,
|
||||
playback: true,
|
||||
placement: 'append',
|
||||
token: this.renderWindowToken,
|
||||
generation
|
||||
generation,
|
||||
deferRenderedMark: useWebGLBookReveal
|
||||
});
|
||||
if (!element) return null;
|
||||
if (!isCurrent()) {
|
||||
@@ -1008,7 +1012,13 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
if (sentence.kind === 'image') {
|
||||
this.revealImageBlock(element);
|
||||
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
||||
if (useWebGLBookReveal) {
|
||||
await this.prepareWebGLBookReveal(sentence);
|
||||
}
|
||||
await this.playbackCoordinator.play(sentence);
|
||||
if (useWebGLBookReveal && sentence.blockId != null) {
|
||||
this.markBlockRendered(sentence.blockId);
|
||||
}
|
||||
} else if (sentence.kind === 'music') {
|
||||
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() {
|
||||
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
||||
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
||||
@@ -1097,7 +1143,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
renderedItemsTarget = this.renderedItems,
|
||||
token = null,
|
||||
recordMetrics = true,
|
||||
generation = this.displayGeneration
|
||||
generation = this.displayGeneration,
|
||||
deferRenderedMark = false
|
||||
} = options;
|
||||
if (!item || !this.paragraphContainer) return null;
|
||||
const renderable = await this.prepareRenderableBlock(item);
|
||||
@@ -1144,7 +1191,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
if (item.blockId != null) {
|
||||
element.dataset.storyBlockId = String(item.blockId);
|
||||
this.markBlockRendered(item.blockId);
|
||||
if (!deferRenderedMark) this.markBlockRendered(item.blockId);
|
||||
}
|
||||
element.dataset.lineStart = String(renderable.lineStart);
|
||||
element.dataset.lineCount = String(renderable.lineCount);
|
||||
|
||||
+110
-63
@@ -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 { 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 { 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');
|
||||
canvas.style.cursor = 'grab';
|
||||
@@ -184,17 +184,15 @@ let pendingPageFlips = 0;
|
||||
|
||||
const paperColor = new THREE.Color(0xece4ca);
|
||||
const inkColor = '#1a1009';
|
||||
const maxRevealWords = 128;
|
||||
const completedRevealElapsedMs = 1000000000;
|
||||
|
||||
await reportLabStep(48, 'Preparing high-resolution page textures');
|
||||
const leftCanvas = createPageCanvas('left');
|
||||
const rightCanvas = createPageCanvas('right');
|
||||
const leftRevealCanvas = createPageCanvas('left');
|
||||
const rightRevealCanvas = createPageCanvas('right');
|
||||
const leftTexture = new THREE.CanvasTexture(leftCanvas);
|
||||
const rightTexture = new THREE.CanvasTexture(rightCanvas);
|
||||
const leftRevealTexture = new THREE.CanvasTexture(leftRevealCanvas);
|
||||
const rightRevealTexture = new THREE.CanvasTexture(rightRevealCanvas);
|
||||
[leftTexture, rightTexture, leftRevealTexture, rightRevealTexture].forEach((texture) => {
|
||||
[leftTexture, rightTexture].forEach((texture) => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = maxTextureAnisotropy;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
@@ -205,14 +203,6 @@ const pageRevealState = {
|
||||
left: null,
|
||||
right: null
|
||||
};
|
||||
const pageRevealCanvases = {
|
||||
left: leftRevealCanvas,
|
||||
right: rightRevealCanvas
|
||||
};
|
||||
const pageRevealTextures = {
|
||||
left: leftRevealTexture,
|
||||
right: rightRevealTexture
|
||||
};
|
||||
await reportLabStep(52, 'Generating leather texture set');
|
||||
const leatherTextures = createLeatherTextures();
|
||||
await reportLabStep(56, 'Generating spine cloth texture set');
|
||||
@@ -357,12 +347,10 @@ const materials = {
|
||||
})
|
||||
};
|
||||
materials.leftPage.userData.bookPageReveal = {
|
||||
side: 'left',
|
||||
texture: leftRevealTexture
|
||||
side: 'left'
|
||||
};
|
||||
materials.rightPage.userData.bookPageReveal = {
|
||||
side: 'right',
|
||||
texture: rightRevealTexture
|
||||
side: 'right'
|
||||
};
|
||||
materials.spineCloth.userData.isSpineCloth = true;
|
||||
materials.headband.userData.isHeadband = true;
|
||||
@@ -475,6 +463,12 @@ window.BookLabDebug = {
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
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();
|
||||
installCameraControls();
|
||||
resize();
|
||||
@@ -568,10 +562,12 @@ function configureBookShadowReceiver(material, strength) {
|
||||
shader.uniforms.bookShadowReceiverStrength = { value: strength };
|
||||
shader.uniforms.bookTableTopY = { value: tableTopY };
|
||||
if (pageReveal) {
|
||||
shader.uniforms.bookRevealMap = { value: pageReveal.texture };
|
||||
shader.uniforms.bookRevealActive = { value: 0 };
|
||||
shader.uniforms.bookRevealProgress = { value: 1 };
|
||||
shader.uniforms.bookRevealBounds = { value: new THREE.Vector4(0, 0, 1, 1) };
|
||||
shader.uniforms.bookRevealElapsedMs = { value: completedRevealElapsedMs };
|
||||
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 };
|
||||
material.userData.bookRevealShader = shader;
|
||||
}
|
||||
@@ -608,18 +604,29 @@ function configureBookShadowReceiver(material, strength) {
|
||||
uniform vec2 bookShadowMapTexelSize;
|
||||
uniform float bookShadowReceiverStrength;
|
||||
uniform float bookTableTopY;
|
||||
${pageReveal ? `uniform sampler2D bookRevealMap;
|
||||
uniform float bookRevealActive;
|
||||
uniform float bookRevealProgress;
|
||||
uniform vec4 bookRevealBounds;
|
||||
${pageReveal ? `uniform float bookRevealActive;
|
||||
uniform float bookRevealElapsedMs;
|
||||
uniform int bookRevealWordCount;
|
||||
uniform vec4 bookRevealWordRects[128];
|
||||
uniform vec4 bookRevealWordTimings[128];
|
||||
uniform vec3 bookRevealPaperColor;
|
||||
uniform float bookRevealSoftness;
|
||||
|
||||
float bookRevealMask(vec2 uv) {
|
||||
vec2 local = (uv - bookRevealBounds.xy) / max(bookRevealBounds.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);
|
||||
float diagonal = clamp((local.x + (1.0 - local.y)) * 0.5, 0.0, 1.0);
|
||||
float feather = max(0.0001, bookRevealSoftness);
|
||||
return inside * smoothstep(diagonal - feather, diagonal + feather, bookRevealProgress);
|
||||
float bookRevealVisibleMask(vec2 uv) {
|
||||
float hidden = 0.0;
|
||||
for (int i = 0; i < 128; i++) {
|
||||
if (i >= bookRevealWordCount) break;
|
||||
vec4 rect = bookRevealWordRects[i];
|
||||
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 vBookReceiverWorldNormal;
|
||||
@@ -764,8 +771,10 @@ function configureBookShadowReceiver(material, strength) {
|
||||
`#ifdef USE_MAP
|
||||
vec4 sampledDiffuseColor = texture2D(map, vMapUv);
|
||||
if (bookRevealActive > 0.5) {
|
||||
vec4 revealDiffuseColor = texture2D(bookRevealMap, vMapUv);
|
||||
sampledDiffuseColor = mix(sampledDiffuseColor, revealDiffuseColor, bookRevealMask(vMapUv));
|
||||
float hiddenInk = bookRevealVisibleMask(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;
|
||||
#endif`
|
||||
@@ -1640,44 +1649,58 @@ function uploadPageTextureDirect(side, sourceCanvas) {
|
||||
}
|
||||
|
||||
function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
|
||||
const revealCanvas = pageRevealCanvases[side];
|
||||
const revealTexture = pageRevealTextures[side];
|
||||
if (!revealCanvas || !revealTexture) {
|
||||
uploadPageTextureDirect(side, sourceCanvas);
|
||||
return;
|
||||
}
|
||||
|
||||
drawCanvasPageTexture(revealCanvas, sourceCanvas, side);
|
||||
const canvas = side === 'left' ? leftCanvas : rightCanvas;
|
||||
const texture = side === 'left' ? leftTexture : rightTexture;
|
||||
const shader = getPageRevealShader(side);
|
||||
if (!shader?.uniforms) {
|
||||
uploadPageTextureDirect(side, sourceCanvas);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = revealDetail.bounds || {};
|
||||
const x = THREE.MathUtils.clamp(Number(bounds.x || 0), 0, 1);
|
||||
const y = THREE.MathUtils.clamp(Number(bounds.y || 0), 0, 1);
|
||||
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;
|
||||
drawCanvasPageTexture(canvas, sourceCanvas, side);
|
||||
texture.needsUpdate = true;
|
||||
applyPageRevealWords(shader, revealDetail.wordRects || []);
|
||||
shader.uniforms.bookRevealActive.value = 1;
|
||||
shader.uniforms.bookRevealMap.value = revealTexture;
|
||||
revealTexture.needsUpdate = true;
|
||||
shader.uniforms.bookRevealElapsedMs.value = 0;
|
||||
|
||||
pageRevealState[side] = {
|
||||
startedAt: performance.now(),
|
||||
startedAt: revealDetail.startNow ? performance.now() : null,
|
||||
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
|
||||
revealCanvas,
|
||||
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) {
|
||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||
return material?.userData?.bookRevealShader || null;
|
||||
@@ -1688,10 +1711,34 @@ function clearPageReveal(side) {
|
||||
const shader = getPageRevealShader(side);
|
||||
if (shader?.uniforms?.bookRevealActive) {
|
||||
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) {
|
||||
['left', 'right'].forEach((side) => {
|
||||
const state = pageRevealState[side];
|
||||
@@ -1701,14 +1748,14 @@ function updatePageRevealAnimations(now) {
|
||||
clearPageReveal(side);
|
||||
return;
|
||||
}
|
||||
if (state.startedAt == null) {
|
||||
shader.uniforms.bookRevealElapsedMs.value = 0;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
const canvas = side === 'left' ? leftCanvas : rightCanvas;
|
||||
const texture = side === 'left' ? leftTexture : rightTexture;
|
||||
drawCanvasPageTexture(canvas, state.revealCanvas, side);
|
||||
texture.needsUpdate = true;
|
||||
clearPageReveal(side);
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
|
||||
detail: {
|
||||
|
||||
Reference in New Issue
Block a user