Checkpoint WebGL book reveal optimization

This commit is contained in:
2026-06-08 08:19:20 +02:00
parent 7abd3387f3
commit c86a304364
13 changed files with 618 additions and 112 deletions
+148 -25
View File
@@ -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-typography-a';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260608-webgl-mask-timing-c';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -214,13 +214,31 @@ const leftCanvas = createPageCanvas('left');
const rightCanvas = createPageCanvas('right');
const leftTexture = new THREE.CanvasTexture(leftCanvas);
const rightTexture = new THREE.CanvasTexture(rightCanvas);
[leftTexture, rightTexture].forEach((texture) => {
function configurePageCanvasTexture(texture) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
texture.generateMipmaps = false;
return texture;
}
[leftTexture, rightTexture].forEach(configurePageCanvasTexture);
function createPageCanvasTexture(sourceCanvas) {
if (!sourceCanvas) return null;
const texture = configurePageCanvasTexture(new THREE.CanvasTexture(sourceCanvas));
texture.needsUpdate = true;
if (typeof renderer?.initTexture === 'function') {
renderer.initTexture(texture);
texture.needsUpdate = false;
}
return texture;
}
const preparedPageTextures = {
left: new Map(),
right: new Map()
};
const pageRevealState = {
left: null,
right: null
@@ -585,7 +603,7 @@ function configureBookShadowReceiver(material, strength) {
const isHardcoverPaper = material.userData?.isHardcoverPaper === true;
const isHeadband = material.userData?.isHeadband === true;
const pageReveal = material.userData?.bookPageReveal || null;
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-v1' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-v2' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
material.onBeforeCompile = (shader) => {
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
@@ -599,7 +617,9 @@ function configureBookShadowReceiver(material, strength) {
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.bookRevealBaseMap = { value: leftTexture };
shader.uniforms.bookRevealUseBaseMap = { value: 0 };
shader.uniforms.bookRevealSoftness = { value: 0.025 };
material.userData.bookRevealShader = shader;
applyPendingPageReveal(pageReveal.side, shader);
}
@@ -642,6 +662,8 @@ function configureBookShadowReceiver(material, strength) {
uniform vec4 bookRevealWordRects[256];
uniform vec4 bookRevealWordTimings[256];
uniform vec3 bookRevealPaperColor;
uniform sampler2D bookRevealBaseMap;
uniform float bookRevealUseBaseMap;
uniform float bookRevealSoftness;
float bookRevealVisibleMask(vec2 uv) {
@@ -653,7 +675,7 @@ function configureBookShadowReceiver(material, strength) {
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 scan = clamp(local.x * 0.88 + (1.0 - local.y) * 0.12, 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));
@@ -805,8 +827,9 @@ function configureBookShadowReceiver(material, strength) {
if (bookRevealActive > 0.5) {
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);
float inkMask = 1.0 - smoothstep(0.52, 0.9, luminance);
vec3 revealBaseColor = mix(bookRevealPaperColor, texture2D(bookRevealBaseMap, vMapUv).rgb, bookRevealUseBaseMap);
sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, revealBaseColor, clamp(hiddenInk * inkMask * 1.55, 0.0, 1.0));
}
diffuseColor *= sampledDiffuseColor;
#endif`
@@ -1653,8 +1676,15 @@ function handlePageCanvases(event) {
markPageTextureTiming('handlePageCanvases:start', {
hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right),
revealSides: Object.keys(detail.reveal || {})
revealSides: Object.keys(detail.reveal || {}),
preloadOnly: Boolean(detail.preloadOnly)
});
if (detail.preloadOnly) {
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
if (detail.right) preloadPageTexture('right', detail.right, detail.reveal?.right);
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
return;
}
if (detail.left) {
if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left);
@@ -1678,10 +1708,59 @@ function handlePageCanvases(event) {
markPageTextureTiming('handlePageCanvases:end');
}
function getRevealCacheKey(revealDetail = {}) {
const ids = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [];
return ids.map(id => String(id)).join('|') || 'direct';
}
function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
if (!sourceCanvas) return null;
const texture = createPageCanvasTexture(sourceCanvas);
const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null;
const key = getRevealCacheKey(revealDetail);
markPageTextureTiming('preloadTexture:start', {
side,
key,
width: sourceCanvas.width,
height: sourceCanvas.height,
hasBaseTexture: Boolean(baseTexture)
});
preparedPageTextures[side].set(key, {
texture,
baseTexture,
sourceCanvas,
revealDetail,
uploadedAt: performance.now()
});
if (preparedPageTextures[side].size > 12) {
const oldestKey = preparedPageTextures[side].keys().next().value;
const oldest = preparedPageTextures[side].get(oldestKey);
oldest?.texture?.dispose?.();
oldest?.baseTexture?.dispose?.();
preparedPageTextures[side].delete(oldestKey);
}
markPageTextureTiming('preloadTexture:end', { side, key });
return texture;
}
function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail);
const prepared = preparedPageTextures[side].get(key);
if (!prepared) return null;
preparedPageTextures[side].delete(key);
markPageTextureTiming('preloadTexture:activate', { side, key });
return prepared;
}
function uploadPageTextureDirect(side, sourceCanvas) {
const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
markPageTextureTiming('directUpload:start', { side });
clearPageReveal(side, 'direct-upload');
if (material.map !== texture) {
material.map = texture;
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas);
markPageTextureTiming('directUpload:end', { side });
}
@@ -1689,12 +1768,25 @@ function uploadPageTextureDirect(side, sourceCanvas) {
function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
const texture = side === 'left' ? leftTexture : rightTexture;
const shader = getPageRevealShader(side);
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const prepared = takePreparedPageTexture(side, revealDetail);
markPageTextureTiming('revealUpload:start', {
side,
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0
wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0,
usedPreparedTexture: Boolean(prepared),
usedPreparedBaseTexture: Boolean(prepared?.baseTexture)
});
bindPageTextureSource(side, texture, sourceCanvas);
if (prepared?.texture) {
material.map = prepared.texture;
} else {
if (material.map !== texture) {
material.map = texture;
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas);
}
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null);
pageRevealState[side] = {
startedAt: revealDetail.startNow ? performance.now() : null,
@@ -1702,9 +1794,13 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
lastRevealFrameAt: null,
visualElapsedMs: 0,
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [],
baseTexture,
fastForwarding: false,
fastForwardStartedAt: null,
fastForwardStartElapsedMs: 0,
fastForwardDurationMs: 260
};
const material = side === 'left' ? materials.leftPage : materials.rightPage;
if (material?.userData) material.userData.pendingPageReveal = revealDetail;
if (shader?.uniforms) applyPendingPageReveal(side, shader);
else if (material) material.needsUpdate = true;
@@ -1725,6 +1821,9 @@ function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
applyPageRevealWords(shader, revealDetail.wordRects || []);
shader.uniforms.bookRevealActive.value = 1;
shader.uniforms.bookRevealElapsedMs.value = 0;
const baseTexture = pageRevealState[side]?.baseTexture;
if (shader.uniforms.bookRevealBaseMap) shader.uniforms.bookRevealBaseMap.value = baseTexture || (side === 'left' ? leftTexture : rightTexture);
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = baseTexture ? 1 : 0;
document.documentElement.dataset.webglRevealDebug = JSON.stringify({
side,
blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [],
@@ -1744,6 +1843,12 @@ function applyPageRevealWords(shader, words = []) {
source.forEach((word, index) => {
const rect = word.rect || {};
const timing = word.timing || {};
const nextTiming = source[index + 1]?.timing || {};
const delay = Math.max(0, Number(timing.delay || 0));
const nextDelay = Number(nextTiming.delay);
const allottedDuration = Number.isFinite(nextDelay) && nextDelay > delay
? nextDelay - delay
: Number(timing.duration || 1);
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);
@@ -1755,8 +1860,8 @@ function applyPageRevealWords(shader, words = []) {
Math.max(0.0001, height)
);
timingUniforms[index].set(
Math.max(0, Number(timing.delay || 0)),
Math.max(1, Number(timing.duration || 1)),
delay,
Math.max(1, allottedDuration),
0,
0
);
@@ -1781,6 +1886,8 @@ function getRevealDebugState() {
elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0),
visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0),
wordCount: Number(uniforms.bookRevealWordCount?.value || 0),
usesBaseTexture: Number(uniforms.bookRevealUseBaseMap?.value || 0),
fastForwarding: pageRevealState[side]?.fastForwarding === true,
started: pageRevealState[side]?.startedAt != null,
pendingStart: pageRevealState[side]?.pendingStart === true,
durationMs: Number(pageRevealState[side]?.durationMs || 0),
@@ -1791,16 +1898,17 @@ function getRevealDebugState() {
}
function clearPageReveal(side, reason = 'clear') {
const previousState = pageRevealState[side];
pageRevealClearLog.push({
side,
reason,
at: performance.now(),
state: pageRevealState[side] ? {
started: pageRevealState[side].startedAt != null,
pendingStart: pageRevealState[side].pendingStart === true,
visualElapsedMs: pageRevealState[side].visualElapsedMs || 0,
durationMs: pageRevealState[side].durationMs,
blockIds: pageRevealState[side].blockIds || []
state: previousState ? {
started: previousState.startedAt != null,
pendingStart: previousState.pendingStart === true,
visualElapsedMs: previousState.visualElapsedMs || 0,
durationMs: previousState.durationMs,
blockIds: previousState.blockIds || []
} : null
});
if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40);
@@ -1811,7 +1919,9 @@ function clearPageReveal(side, reason = 'clear') {
shader.uniforms.bookRevealActive.value = 0;
shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs;
shader.uniforms.bookRevealWordCount.value = 0;
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0;
}
previousState?.baseTexture?.dispose?.();
}
function startPageRevealForBlock(blockId) {
@@ -1833,7 +1943,10 @@ function fastForwardPageReveals(blockIds = []) {
if (!state) return;
const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId)));
if (!matches) return;
clearPageReveal(side, 'fast-forward');
state.fastForwarding = true;
state.fastForwardStartedAt = performance.now();
state.fastForwardStartElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0));
state.fastForwardDurationMs = 260;
});
}
@@ -1860,7 +1973,17 @@ function updatePageRevealAnimations(now) {
}
const revealFrameDeltaMs = state.lastRevealFrameAt == null ? 0 : Math.max(0, now - state.lastRevealFrameAt);
state.lastRevealFrameAt = now;
state.visualElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)) + Math.min(revealFrameDeltaMs, targetFrameDurationMs);
if (state.fastForwarding) {
const fastElapsed = Math.max(0, now - Number(state.fastForwardStartedAt || now));
const fastProgress = THREE.MathUtils.clamp(fastElapsed / Math.max(1, Number(state.fastForwardDurationMs || 1)), 0, 1);
state.visualElapsedMs = THREE.MathUtils.lerp(
Math.max(0, Number(state.fastForwardStartElapsedMs || 0)),
state.durationMs,
fastProgress
);
} else {
state.visualElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)) + Math.min(revealFrameDeltaMs, targetFrameDurationMs);
}
const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1);
shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs;
if (progress < 1) return;