Queue WebGL book reveal masks

This commit is contained in:
2026-06-07 13:52:07 +02:00
parent 7fc083fb58
commit 9434950826
31 changed files with 383 additions and 73 deletions
+110 -63
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-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: {