Add shader page reveal checkpoint

This commit is contained in:
2026-06-07 13:10:17 +02:00
parent 7725ce9c73
commit 7fc083fb58
5 changed files with 255 additions and 65 deletions
+161 -7
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-paper-loader-fix';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-shader-reveal';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -188,15 +188,31 @@ const inkColor = '#1a1009';
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);
[leftTexture, rightTexture].forEach((texture) => {
const leftRevealTexture = new THREE.CanvasTexture(leftRevealCanvas);
const rightRevealTexture = new THREE.CanvasTexture(rightRevealCanvas);
[leftTexture, rightTexture, leftRevealTexture, rightRevealTexture].forEach((texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
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');
@@ -340,6 +356,14 @@ const materials = {
envMapIntensity: 0
})
};
materials.leftPage.userData.bookPageReveal = {
side: 'left',
texture: leftRevealTexture
};
materials.rightPage.userData.bookPageReveal = {
side: 'right',
texture: rightRevealTexture
};
materials.spineCloth.userData.isSpineCloth = true;
materials.headband.userData.isHeadband = true;
configureHardcoverPaperMaterial(materials.pageBlock);
@@ -535,13 +559,22 @@ function configureBookShadowReceiver(material, strength) {
const isSpineCloth = material.userData?.isSpineCloth === true;
const isHardcoverPaper = material.userData?.isHardcoverPaper === true;
const isHeadband = material.userData?.isHeadband === true;
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
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.onBeforeCompile = (shader) => {
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
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.bookRevealSoftness = { value: 0.035 };
material.userData.bookRevealShader = shader;
}
shader.vertexShader = shader.vertexShader
.replace(
@@ -575,6 +608,19 @@ 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;
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);
}` : ''}
varying vec3 vBookReceiverWorldPosition;
varying vec3 vBookReceiverWorldNormal;
${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}
@@ -712,6 +758,19 @@ function configureBookShadowReceiver(material, strength) {
outgoingLight += bookLocalBounce(vBookReceiverWorldPosition, normalize(vBookReceiverWorldNormal), bookReceiverShadow, diffuseColor.rgb);
#include <opaque_fragment>`
);
if (pageReveal) {
shader.fragmentShader = shader.fragmentShader.replace(
'#include <map_fragment>',
`#ifdef USE_MAP
vec4 sampledDiffuseColor = texture2D(map, vMapUv);
if (bookRevealActive > 0.5) {
vec4 revealDiffuseColor = texture2D(bookRevealMap, vMapUv);
sampledDiffuseColor = mix(sampledDiffuseColor, revealDiffuseColor, bookRevealMask(vMapUv));
}
diffuseColor *= sampledDiffuseColor;
#endif`
);
}
};
}
@@ -1551,12 +1610,18 @@ function syncBookControls() {
function handlePageCanvases(event) {
const detail = event.detail || {};
if (detail.left) {
drawCanvasPageTexture(leftCanvas, detail.left, 'left');
leftTexture.needsUpdate = true;
if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left);
} else {
uploadPageTextureDirect('left', detail.left);
}
}
if (detail.right) {
drawCanvasPageTexture(rightCanvas, detail.right, 'right');
rightTexture.needsUpdate = true;
if (detail.reveal?.right) {
beginPageReveal('right', detail.right, detail.reveal.right);
} else {
uploadPageTextureDirect('right', detail.right);
}
}
markStaticSceneBuffersDirty();
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
@@ -1566,6 +1631,94 @@ function handlePageCanvases(event) {
});
}
function uploadPageTextureDirect(side, sourceCanvas) {
const canvas = side === 'left' ? leftCanvas : rightCanvas;
const texture = side === 'left' ? leftTexture : rightTexture;
clearPageReveal(side);
drawCanvasPageTexture(canvas, sourceCanvas, side);
texture.needsUpdate = true;
}
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 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;
shader.uniforms.bookRevealActive.value = 1;
shader.uniforms.bookRevealMap.value = revealTexture;
revealTexture.needsUpdate = true;
pageRevealState[side] = {
startedAt: performance.now(),
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
revealCanvas,
blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []
};
}
function getPageRevealShader(side) {
const material = side === 'left' ? materials.leftPage : materials.rightPage;
return material?.userData?.bookRevealShader || null;
}
function clearPageReveal(side) {
pageRevealState[side] = null;
const shader = getPageRevealShader(side);
if (shader?.uniforms?.bookRevealActive) {
shader.uniforms.bookRevealActive.value = 0;
shader.uniforms.bookRevealProgress.value = 1;
}
}
function updatePageRevealAnimations(now) {
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
if (!state) return;
const shader = getPageRevealShader(side);
if (!shader?.uniforms) {
clearPageReveal(side);
return;
}
const progress = THREE.MathUtils.clamp((now - state.startedAt) / state.durationMs, 0, 1);
shader.uniforms.bookRevealProgress.value = progress;
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: {
side,
blockIds: state.blockIds
}
}));
});
}
function drawCanvasPageTexture(canvas, sourceCanvas, side) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f2ead0';
@@ -2998,6 +3151,7 @@ function animate(now = performance.now()) {
const hadActiveFlips = activeFlips.length > 0;
updateActiveFlips(performance.now());
if (hadActiveFlips) markStaticSceneBuffersDirty();
updatePageRevealAnimations(now);
updateCandleShadowUniforms();
renderedFrameCount += 1;
const shadowStartedAt = performance.now();